@cotal-ai/core 0.10.1 → 0.11.1

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 (64) hide show
  1. package/dist/agent-file.d.ts +10 -1
  2. package/dist/agent-file.d.ts.map +1 -1
  3. package/dist/agent-file.js +65 -80
  4. package/dist/agent-file.js.map +1 -1
  5. package/dist/auth-provider.d.ts +207 -0
  6. package/dist/auth-provider.d.ts.map +1 -0
  7. package/dist/auth-provider.js +13 -0
  8. package/dist/auth-provider.js.map +1 -0
  9. package/dist/channels.d.ts +10 -0
  10. package/dist/channels.d.ts.map +1 -1
  11. package/dist/channels.js +9 -27
  12. package/dist/channels.js.map +1 -1
  13. package/dist/command.d.ts +6 -1
  14. package/dist/command.d.ts.map +1 -1
  15. package/dist/command.js +5 -1
  16. package/dist/command.js.map +1 -1
  17. package/dist/connector-config.js +1 -1
  18. package/dist/connector.d.ts +16 -0
  19. package/dist/connector.d.ts.map +1 -1
  20. package/dist/endpoint.d.ts +148 -17
  21. package/dist/endpoint.d.ts.map +1 -1
  22. package/dist/endpoint.js +470 -97
  23. package/dist/endpoint.js.map +1 -1
  24. package/dist/evict.d.ts +87 -0
  25. package/dist/evict.d.ts.map +1 -0
  26. package/dist/evict.js +231 -0
  27. package/dist/evict.js.map +1 -0
  28. package/dist/identity.d.ts +15 -0
  29. package/dist/identity.d.ts.map +1 -1
  30. package/dist/identity.js +26 -2
  31. package/dist/identity.js.map +1 -1
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/launch.d.ts +6 -0
  37. package/dist/launch.d.ts.map +1 -1
  38. package/dist/members.d.ts.map +1 -1
  39. package/dist/members.js +5 -2
  40. package/dist/members.js.map +1 -1
  41. package/dist/membership-feed.d.ts +17 -3
  42. package/dist/membership-feed.d.ts.map +1 -1
  43. package/dist/membership-feed.js +41 -13
  44. package/dist/membership-feed.js.map +1 -1
  45. package/dist/provision.d.ts +99 -14
  46. package/dist/provision.d.ts.map +1 -1
  47. package/dist/provision.js +267 -128
  48. package/dist/provision.js.map +1 -1
  49. package/dist/resolve.js +1 -1
  50. package/dist/secret-fs.d.ts +9 -0
  51. package/dist/secret-fs.d.ts.map +1 -1
  52. package/dist/secret-fs.js +26 -2
  53. package/dist/secret-fs.js.map +1 -1
  54. package/dist/streams.d.ts +33 -7
  55. package/dist/streams.d.ts.map +1 -1
  56. package/dist/streams.js +53 -30
  57. package/dist/streams.js.map +1 -1
  58. package/dist/subjects.d.ts +267 -75
  59. package/dist/subjects.d.ts.map +1 -1
  60. package/dist/subjects.js +401 -110
  61. package/dist/subjects.js.map +1 -1
  62. package/dist/types.d.ts +13 -2
  63. package/dist/types.d.ts.map +1 -1
  64. package/package.json +3 -2
package/dist/subjects.js CHANGED
@@ -51,15 +51,26 @@ function channelPath(channel) {
51
51
  })
52
52
  .join(".");
53
53
  }
54
- /** A routing token (sender, target, role, service), preserving the literal `*` wildcard
55
- * used on the subscribe/allow side but sanitizing everything else. A no-op on real ids
56
- * (nkey public keys are base32 [A-Z0-9]) and equal to `token()` on every concrete value
57
- * it only additionally lets `*` through, e.g. for `inst.*.<id>` / `svc.*.<id>` allow rules. */
54
+ /** A routing token (target, role, service), preserving the literal `*` wildcard used on the
55
+ * subscribe/allow side but sanitizing everything else. A no-op on real ids and equal to `token()`
56
+ * on every concrete value it only additionally lets `*` through, e.g. for `svc.*.…` allow rules.
57
+ * Used for the *service/role* slots (`svc.<role>`, `ctl.<tier>`); the owner/actor identity slots go
58
+ * through {@link ownerToken} (fail-loud, never rewritten). */
58
59
  function routeToken(s) {
59
60
  return s === "*" ? "*" : token(s);
60
61
  }
61
- export function chatSubject(space, sender, channel) {
62
- return `${spacePrefix(space)}.chat.${routeToken(sender)}.${channelPath(channel)}`;
62
+ /** An owner/actor identity token in a wire subject: the literal `*` wildcard passes through (for
63
+ * allow/subscribe rules like `chat.*.*.<ch>`), and every concrete value is {@link assertValidOwnerToken}-
64
+ * validated (fail loud — a `.`/`*`/`>`/`-` in an id is lane breakout, NEVER silently rewritten the way
65
+ * `token()`/`routeToken()` would). The owner+actor grammar's per-subject enforcement point. */
66
+ function ownerToken(s) {
67
+ return s === "*" ? "*" : assertValidOwnerToken(s);
68
+ }
69
+ /** Multicast: `chat.<owner>.<actor>.<channel…>` — the publishing principal (owner+actor) precedes the
70
+ * channel (owner+actor grammar, Shape A). Either identity slot may be `*` for subscribe/allow rules
71
+ * (`chat.*.*.<ch>` = read a channel from any principal). */
72
+ export function chatSubject(space, owner, actor, channel) {
73
+ return `${spacePrefix(space)}.chat.${ownerToken(owner)}.${ownerToken(actor)}.${channelPath(channel)}`;
63
74
  }
64
75
  /** True if a channel names a concrete sub-channel (no `*`/`>`) — i.e. it can be
65
76
  * *published* to. Subscriptions may be wildcard; publishes must be concrete. */
@@ -108,36 +119,224 @@ export function assertValidChannel(channel) {
108
119
  if (s === "*")
109
120
  return;
110
121
  if (!/^[A-Za-z0-9_-]+$/.test(s))
111
- throw new Error(`invalid channel "${channel}": segment "${s}" must be a NATS-safe token ([A-Za-z0-9_-]), '*', or '>' ` +
122
+ throw new Error(`invalid channel "${channel}": segment "${s}" must be a NATS-safe token ([A-Za-z0-9_-]), '*', or '>' - ` +
112
123
  `policy channel names can't contain characters the wire layer would rewrite`);
113
124
  });
114
125
  return channel;
115
126
  }
116
- /** Validate an **owner/actor token** the broker-authenticated lane boundary that the owner+actor
117
- * grammar adds to wire subjects and persisted principal-scoped keys. STRICTER than
118
- * {@link assertValidChannel}: a principal segment is exactly ONE NATS-safe token — `[A-Za-z0-9_]+`,
119
- * with NO dots, NO `*`, NO `>`, and NO `-` (reserved as the JetStream-name separator in
120
- * {@link principalNameKey}). A separator or wildcard is lane breakout or aliasing (it would let one
121
- * owner's grant span, or collide with, another's), so this FAILS LOUD rather than sanitizing.
122
- * Do NOT substitute {@link token}/`routeToken` here: they silently rewrite illegal characters safe
123
- * for nkeys (already base32), unsafe for human-owner ids where a rewrite hides an aliasing attempt.
124
- * Returns the token unchanged when valid so callers can use it inline. */
127
+ /** Validate an **owner or actor token** of the owner+actor grammar (the per-user-auth cutover).
128
+ * Defined AHEAD of use: today this has no call sites — persisted owner-bearing keys
129
+ * ({@link memberKey}, {@link aclKey}, {@link dinboxSubject}, {@link dlvSubject}) still ride raw
130
+ * nkeys through `token()`/`routeToken()` (a no-op on base32) the cutover wires this in at every
131
+ * mint/callout boundary and persisted owner/actor-bearing key. STRICTER than
132
+ * {@link assertValidChannel}: a token is exactly ONE NATS-safe segment `[A-Za-z0-9_]+`, with NO
133
+ * dots, NO `*`, NO `>`, and NO `-`. Dots/wildcards in an id are lane breakout or aliasing (they would
134
+ * let one owner's grant span, or collide with, another's); `-` is excluded because it is reserved as
135
+ * the sole separator of {@link principalKey}'s JetStream-name form a `-` *inside* a token would
136
+ * make `<owner>-<actor>` ambiguous. The ASCII-only alphabet also makes NFC-normalization trivially
137
+ * hold (any non-ASCII input fails). FAILS LOUD rather than sanitizing — do NOT substitute
138
+ * {@link token}/`routeToken` here: they silently rewrite illegal characters, and a rewrite hides an
139
+ * aliasing attempt. Returns the token unchanged when valid so callers can use it inline. */
125
140
  export function assertValidOwnerToken(owner) {
126
- if (!/^[A-Za-z0-9_]+$/.test(owner))
127
- throw new Error(`invalid owner token "${owner}": an owner/actor must be a single NATS-safe token ([A-Za-z0-9_]) — ` +
128
- `no dots, '*', '>', or '-'. A separator or wildcard in an owner id is lane breakout or aliasing, ` +
129
- `so it is rejected rather than silently rewritten.`);
141
+ // typeof guard is load-bearing at JS/JSON boundaries: RegExp.test() coerces its argument, so
142
+ // without it a number like 123 stringifies, matches, and is returned UN-coerced.
143
+ if (typeof owner !== "string" || !/^[A-Za-z0-9_]+$/.test(owner))
144
+ throw new Error(`invalid owner/actor token "${owner}": must be a single NATS-safe token ([A-Za-z0-9_]) - ` +
145
+ `no dots, '*', '>', or '-'. A separator or wildcard in an id is lane breakout or aliasing, ` +
146
+ `and '-' is reserved as the principal name-form separator, so it is rejected rather than ` +
147
+ `silently rewritten.`);
130
148
  return owner;
131
149
  }
132
- /** Principal key form for subjects and KV keys: dot separates owner and actor because NATS subjects use
133
- * dot token boundaries. Use only in subject/KV namespaces, never as a JetStream durable/consumer name. */
150
+ /** The canonical serialization of a **principal** (`owner`+`actor`) the key every authority that
151
+ * enforces per-agent grants checks against. It is TWO forms, not one, because the same principal
152
+ * lands in two namespaces with incompatible rules:
153
+ * - `key` — the subject / KV-key dot-form `<owner>.<actor>`, for wire subjects and KV keys,
154
+ * where `.` is the token boundary;
155
+ * - `name` — the JetStream-name form `<owner>-<actor>`, for durable / consumer / stream and
156
+ * chat-history names, where JetStream forbids `.` `*` `>` `/` and whitespace, so the dot-form
157
+ * is illegal.
158
+ * Both tokens are {@link assertValidOwnerToken}-validated, and `-` is banned *inside* tokens and
159
+ * reserved as the sole name separator, so both forms are collision-free — distinct (owner, actor)
160
+ * pairs can never serialize to the same string. All principal serialization goes through here: no
161
+ * ad-hoc string joins, and never a `<owner>.<actor>` fed to a JetStream name. Defined ahead of the
162
+ * owner+actor cutover — call sites (durables, member/acl keys, grants) arrive with the flip. */
163
+ export function principalKey(owner, actor) {
164
+ assertValidOwnerToken(owner);
165
+ assertValidOwnerToken(actor);
166
+ return { key: `${owner}.${actor}`, name: `${owner}-${actor}` };
167
+ }
168
+ /** Principal key form for subjects and KV keys. Alias for callers/tests that need one form explicitly. */
134
169
  export function principalSubjectKey(owner, actor) {
135
- return `${assertValidOwnerToken(owner)}.${assertValidOwnerToken(actor)}`;
170
+ return principalKey(owner, actor).key;
136
171
  }
137
- /** Principal key form for JetStream names (durables/consumers/streams): `.` is illegal there, so `-` is
138
- * the reserved separator and is banned inside each owner/actor token by {@link assertValidOwnerToken}. */
172
+ /** Principal key form for JetStream names. Alias for callers/tests that need one form explicitly. */
139
173
  export function principalNameKey(owner, actor) {
140
- return `${assertValidOwnerToken(owner)}-${assertValidOwnerToken(actor)}`;
174
+ return principalKey(owner, actor).name;
175
+ }
176
+ /** Inverse of {@link principalKey}'s dot-form `key`: split a principal `<owner>.<actor>` back into its
177
+ * two tokens, or `null` if it isn't a valid one. Owner/actor tokens are `[A-Za-z0-9_]+` (dot-free), so a
178
+ * single `.` separates them unambiguously — exactly two segments, both {@link assertValidOwnerToken}-valid.
179
+ * Used where a stored principal (a member/from.id dot-form) must be re-split to feed the owner+actor
180
+ * subject builders (e.g. fan-out → `dinboxSubject`). */
181
+ /** Resolve a deprovision target: a full principal dot-form (`u_….<actor>`, user-mode agents) or a
182
+ * bare static/dev actor id (an nkey pub — never contains a dot), keyed under {@link DEV_OWNER}.
183
+ * Shared by the deprovisioner permission pin and the teardown helper so they can't diverge. */
184
+ export function deprovisionTargetPrincipal(target) {
185
+ return parsePrincipalKey(target) ?? { owner: DEV_OWNER, actor: target };
186
+ }
187
+ export function parsePrincipalKey(key) {
188
+ if (typeof key !== "string")
189
+ return null;
190
+ const dot = key.indexOf(".");
191
+ if (dot <= 0 || dot >= key.length - 1)
192
+ return null;
193
+ const owner = key.slice(0, dot);
194
+ const actor = key.slice(dot + 1);
195
+ if (!/^[A-Za-z0-9_]+$/.test(owner) || !/^[A-Za-z0-9_]+$/.test(actor))
196
+ return null;
197
+ return { owner, actor };
198
+ }
199
+ /** Validate a connection's **connId** — the single token that fills `_INBOX_<connId>.>`, the private
200
+ * reply-inbox grant. In dev/static mode connId is the agent's own nkey (56 uppercase base32 chars); in
201
+ * user mode it is a client-CHOSEN random inbox nonce (the auth callout can't know the NATS-minted
202
+ * per-connection nkey pre-connect, and the client can't either, so the client picks its own nonce and
203
+ * passes it via the connection `name`). Because that nonce is untrusted client input, the grant builder
204
+ * MUST reject any subject metacharacter here: a connId of `>` would mint `_INBOX_>.>` (every inbox) and a
205
+ * `*`/`.` would widen it too. `[A-Za-z0-9_-]{8,120}` admits nkeys and high-entropy nonces while barring
206
+ * `* > . space` — so `_INBOX_<connId>.>` can never escalate past one connection's own inbox. Fail-loud. */
207
+ export function assertInboxConnId(connId) {
208
+ if (typeof connId !== "string" || !/^[A-Za-z0-9_-]{8,120}$/.test(connId))
209
+ throw new Error(`invalid connId/inbox nonce ${JSON.stringify(connId)} - must match [A-Za-z0-9_-]{8,120} ` +
210
+ `(no subject metacharacters; guards the _INBOX_<connId>.> grant against wildcard escalation)`);
211
+ return connId;
212
+ }
213
+ /** The `tags` prefix carrying a connection's principal dot-form in its minted user JWT. CONNZ
214
+ * surfaces a JWT-authed connection's principal identity DIFFERENTLY by cred shape, proven live on
215
+ * nats-server 2.10/2.14 (see {@link principalFromConnz}): a STATICALLY-minted user (`mintCreds`)
216
+ * surfaces this `principal:` tag (`authorized_user` is the ephemeral connection nkey), while a
217
+ * CALLOUT-minted user surfaces the JWT `name` (the principal NAME-form) as `authorized_user` and NO
218
+ * tags at all. So this tag is the attribution field for STATIC connections only; callout connections
219
+ * are attributed via `authorized_user`. NEVER reintroduce a tags-ONLY read — it silently misses every
220
+ * user-mode connection. Always attribute through {@link principalFromConnz}, which handles both. */
221
+ export const PRINCIPAL_TAG_PREFIX = "principal:";
222
+ /** The identity `tags` stamped into every minted user JWT (dev mint AND the auth callout, via this one
223
+ * helper) — recoverable from a STATIC connection's CONNZ record (a callout connection surfaces the
224
+ * principal via `authorized_user` instead; see {@link PRINCIPAL_TAG_PREFIX}). `owner:`/`actor:` are
225
+ * human/debug breadcrumbs; `principal:` (the dot-form {@link principalKey} `key`) is the one attribution
226
+ * reads. Single source of the tag format — never hand-join these elsewhere. */
227
+ export function principalTags(owner, actor) {
228
+ const { key } = principalKey(owner, actor);
229
+ return [`owner:${owner}`, `actor:${actor}`, `${PRINCIPAL_TAG_PREFIX}${key}`];
230
+ }
231
+ /** Recover a connection's principal dot-form from its CONNZ `tags`, or `null` if absent/malformed. The
232
+ * membership feed calls this to key a live connection by its principal and **fails closed** on `null`
233
+ * (it drops the connection rather than fall back to the ephemeral nkey — a tagless connection is not a
234
+ * principal we can attribute). Validates via {@link parsePrincipalKey} so a forged/garbled tag can't
235
+ * smuggle a non-principal string into the feed. */
236
+ export function principalFromTags(tags) {
237
+ if (!tags)
238
+ return null;
239
+ const tag = tags.find((t) => t.startsWith(PRINCIPAL_TAG_PREFIX));
240
+ if (!tag)
241
+ return null;
242
+ const key = tag.slice(PRINCIPAL_TAG_PREFIX.length);
243
+ // Same trust boundary as the message-surfacing guards ({@link isPrincipalOwnerToken}): a recovered
244
+ // principal must have a REAL owner (derived `u_…` or `local`), not just valid dot-form syntax — else a
245
+ // `principal:<nkey>.team` tag would key a live feed entry on an nkey-shaped owner the surfacing path
246
+ // rejects. Fail closed on anything else.
247
+ const p = parsePrincipalKey(key);
248
+ return p && isPrincipalOwnerToken(p.owner) ? key : null;
249
+ }
250
+ /** Inverse of {@link principalKey}'s `name` form (`<owner>-<actor>`): recover the principal dot-form
251
+ * from a name-form string, or `null` if it isn't one. Owner tokens are `[A-Za-z0-9_]+` and actor
252
+ * tokens too — `-` is reserved as the name-form separator — so the FIRST `-` splits owner from actor
253
+ * unambiguously, and both halves must be {@link parsePrincipalKey}-valid with a real principal owner.
254
+ * An nkey (no `-`) or any other non-name-form returns null. */
255
+ export function principalFromName(name) {
256
+ if (typeof name !== "string")
257
+ return null;
258
+ const dash = name.indexOf("-");
259
+ if (dash <= 0 || dash >= name.length - 1)
260
+ return null;
261
+ const key = `${name.slice(0, dash)}.${name.slice(dash + 1)}`;
262
+ const p = parsePrincipalKey(key);
263
+ return p && isPrincipalOwnerToken(p.owner) ? key : null;
264
+ }
265
+ /** Recover a connection's principal dot-form from a `$SYS` CONNZ record, across BOTH credential
266
+ * shapes — the one place that knows nats-server surfaces principal identity differently for each:
267
+ * - a STATICALLY-minted user (`mintCreds`) surfaces its `principal:` TAG (`authorized_user` is the
268
+ * ephemeral connection nkey);
269
+ * - a CALLOUT-minted user surfaces the JWT `name` (the principal NAME-form) as `authorized_user`,
270
+ * and does NOT surface `tags` at all (proven live on nats-server 2.10.22 + 2.14.2).
271
+ * So attribution must try the tag first, then the `authorized_user` name-form; anything else (an
272
+ * un-tagged nkey, open mode, infra) is `null` — unattributable, dropped fail-closed by callers.
273
+ * The membership feed and live eviction both key on this, so it lives here as the single source. */
274
+ export function principalFromConnz(conn) {
275
+ return principalFromTags(conn.tags) ?? principalFromName(conn.authorized_user);
276
+ }
277
+ /** The reserved owner token for the **no-login local/dev path** — the static-creds default when there
278
+ * is no user identity (the plan's zero-login local default). A valid {@link assertValidOwnerToken}
279
+ * token, and — being lowercase-alpha with no `u_` prefix — trivially distinct from both nkeys and
280
+ * {@link assertDerivedOwnerToken} `u_…` owners, so a dev agent can never collide with a real owner
281
+ * lane. In dev the actor is the connection id, so `local.<id>` is the agent's principal. */
282
+ export const DEV_OWNER = "local";
283
+ /** Prefix of every **derived owner token** — see {@link assertDerivedOwnerToken}. */
284
+ export const DERIVED_OWNER_PREFIX = "u_";
285
+ /** Validate the structural format of a **derived owner token** — the opaque, per-space, non-PII
286
+ * token the auth callout derives server-side for a human owner: `u_` + 26 lowercase base32
287
+ * (`[a-z2-7]`) chars (128 keyed-HMAC bits; the derivation lives in `@cotal-ai/auth`, this is the
288
+ * format contract). The format is **structurally disjoint from nkeys by construction** — the
289
+ * owner+actor flip's acceptance criterion 2: an nkey public key is 56 chars of UPPERCASE base32
290
+ * (`[A-Z2-7]`), while a derived token is 28 chars, contains `_`, and its body is lowercase — three
291
+ * independent properties an nkey can never satisfy. So a pre-flip agent id (an nkey) can NEVER
292
+ * parse as a valid new owner, which closes the old-shape-aliases-new-read lane
293
+ * (`chat.<nkey>.team.backend` matching `chat.*.*.backend` cannot yield a plausible owner). Every
294
+ * derived token also trivially passes {@link assertValidOwnerToken} (subset alphabet, no `-`).
295
+ * Defined ahead of use: enforced at the callout/mint boundary when the cutover lands. Returns the
296
+ * token unchanged when valid so callers can use it inline. */
297
+ export function assertDerivedOwnerToken(owner) {
298
+ if (typeof owner !== "string" || !/^u_[a-z2-7]{26}$/.test(owner))
299
+ throw new Error(`invalid derived owner token "${owner}": expected "${DERIVED_OWNER_PREFIX}" + 26 lowercase ` +
300
+ `base32 chars ([a-z2-7]). Owner tokens are derived server-side at the auth callout and are ` +
301
+ `structurally disjoint from nkeys; anything else at an owner boundary is rejected, not rewritten.`);
302
+ return owner;
303
+ }
304
+ /** Validate an owner token at a READ / persisted-owner TRUST boundary — STRICTER than
305
+ * {@link assertValidOwnerToken}, which (by design) still accepts nkey-shaped uppercase tokens and so does
306
+ * NOT by itself satisfy the flip's acceptance criterion 2. A *real* owner is EITHER a derived owner
307
+ * ({@link assertDerivedOwnerToken}: `u_` + 26 lowercase base32, minted at the callout) OR — when
308
+ * `allowLocal` — the reserved no-login dev owner {@link DEV_OWNER} (`"local"`). An nkey (56-char UPPERCASE
309
+ * base32) satisfies neither, so a stray old-shape frame `chat.<nkey>.team.backend` can never be TRUSTED as
310
+ * a valid owner even if it structurally parses — the belt to the from.id≠sender guard's braces and cred
311
+ * death. Use at every boundary that reads an owner from a wire subject / persisted key and then trusts it
312
+ * for keying, surfacing, or authorization (membership feed re-key, history surfacing). Actors stay on
313
+ * {@link assertValidOwnerToken} — they are server-derived from the ledger, not disjointness-constrained.
314
+ * User-mode MINT boundaries (callout/bridge) use {@link assertDerivedOwnerToken} directly (no `local`). */
315
+ export function assertPrincipalOwnerToken(owner, opts = {}) {
316
+ if (opts.allowLocal && owner === DEV_OWNER)
317
+ return owner;
318
+ try {
319
+ return assertDerivedOwnerToken(owner);
320
+ }
321
+ catch {
322
+ throw new Error(`invalid principal owner "${owner}" at a trust boundary: expected a derived owner (u_…)` +
323
+ `${opts.allowLocal ? ` or the reserved dev owner "${DEV_OWNER}"` : ""} - an nkey-shaped or arbitrary ` +
324
+ `token is not a real owner (flip criterion 2: owners are nkey-disjoint).`);
325
+ }
326
+ }
327
+ /** Non-throwing {@link assertPrincipalOwnerToken} for hot per-message drop guards — true iff `owner` is a
328
+ * real principal owner (a derived `u_…`, or `local` when `allowLocal`). The message-surfacing paths call
329
+ * this on `parsed.owner` alongside the `from.id === parsed.sender` check, so a structurally-valid old-shape
330
+ * alias (`chat.<nkey>.team.backend`, owner = an nkey) is DROPPED at read time — belt to cred death, not a
331
+ * dependency on it. `allowLocal` defaults true: the dev/static path is a legitimate live sender. */
332
+ export function isPrincipalOwnerToken(owner, opts = { allowLocal: true }) {
333
+ try {
334
+ assertPrincipalOwnerToken(owner, { allowLocal: opts.allowLocal ?? true });
335
+ return true;
336
+ }
337
+ catch {
338
+ return false;
339
+ }
141
340
  }
142
341
  /** Is `channel` within a read/post ACL `allow` (a list of channel patterns)? True when some
143
342
  * entry covers it — exact, or a wildcard subtree (`team.>` covers `team.backend`). Channels are
@@ -147,6 +346,43 @@ export function principalNameKey(owner, actor) {
147
346
  export function channelInAllow(allow, channel) {
148
347
  return allow.some((a) => subjectMatches(a, channel));
149
348
  }
349
+ /** Does policy pattern `cap` COVER policy pattern `pattern` — i.e. is every channel matched by
350
+ * `pattern` also matched by `cap`? Both sides use the {@link assertValidChannel} grammar
351
+ * (`*` = one token, `>` = one-or-more trailing). This is the DELEGATION primitive
352
+ * ({@link patternInAllow}): where {@link channelInAllow} asks "is this concrete channel readable
353
+ * under this ACL", this asks "is this ACL *entry* grantable under this ACL" — pattern vs pattern,
354
+ * so `review.>` is within `review.>` but exceeds `review.pua`. */
355
+ export function patternCovers(cap, pattern) {
356
+ const c = cap.split(".");
357
+ const p = pattern.split(".");
358
+ // A non-terminal '>' is outside the policy grammar (assertValidChannel) — fail closed on BOTH
359
+ // sides: a malformed cap must never widen an envelope (`review.>.x` is not `review.>`), and a
360
+ // malformed request must never be admitted.
361
+ if (c.slice(0, -1).includes(">") || p.slice(0, -1).includes(">"))
362
+ return false;
363
+ for (let i = 0; i < c.length; i++) {
364
+ if (c[i] === ">")
365
+ return p.length > i; // every match of `pattern` has ≥ i+1 tokens iff pattern still has a token here
366
+ if (i >= p.length)
367
+ return false; // pattern's matches are exactly p.length tokens — shorter than cap requires
368
+ if (p[i] === ">")
369
+ return false; // pattern fans out into arbitrary depth this cap segment can't cover
370
+ if (c[i] === "*")
371
+ continue; // any single token is covered
372
+ if (p[i] === "*")
373
+ return false; // pattern admits tokens this literal cap segment does not
374
+ if (c[i] !== p[i])
375
+ return false;
376
+ }
377
+ return c.length === p.length;
378
+ }
379
+ /** Is ACL entry `pattern` within capability list `allow` — covered by SOME single entry? A union of
380
+ * entries is deliberately NOT considered (`[a.b, a.c]` does not admit `a.*`): per-entry containment
381
+ * is conservative — it can refuse a technically-covered pattern, never admit an uncovered one —
382
+ * and keeps the refusal explainable ("name the entry that covers it, or widen the grant"). */
383
+ export function patternInAllow(allow, pattern) {
384
+ return allow.some((a) => patternCovers(a, pattern));
385
+ }
150
386
  /** Drop exact duplicates and any subject subsumed by a more-general one — JetStream
151
387
  * rejects a consumer whose `filter_subjects` overlap, so `[team.>, team.backend]`
152
388
  * must collapse to `[team.>]` before binding the chat consumer. A parent and its subtree
@@ -156,18 +392,33 @@ export function collapseFilterSubjects(subjects) {
156
392
  const uniq = [...new Set(subjects)];
157
393
  return uniq.filter((x) => !uniq.some((y) => y !== x && subjectMatches(y, x)));
158
394
  }
159
- /** Unicast: a specific instance's inbox, tagged with the sender. (Either position may be
160
- * `*` for subscribe/allow rules: `inst.<myId>.*` to receive, `inst.*.<myId>` to send as me.) */
161
- export function unicastSubject(space, target, sender) {
162
- return `${spacePrefix(space)}.inst.${routeToken(target)}.${routeToken(sender)}`;
163
- }
164
- /** Anycast: a service (role), tagged with the sender. Subscribers join a queue group so one instance receives. */
165
- export function anycastSubject(space, service, sender) {
166
- return `${spacePrefix(space)}.svc.${routeToken(service)}.${routeToken(sender)}`;
167
- }
168
- /** Control request/reply to a service (e.g. the manager), tagged with the sender; anycast via queue group. */
169
- export function controlServiceSubject(space, service, sender) {
170
- return `${spacePrefix(space)}.ctl.${routeToken(service)}.${routeToken(sender)}`;
395
+ /** Unicast: `inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor>` the recipient principal, then the
396
+ * sender principal (owner+actor grammar). The 4-token form is deliberate: it lets a native publish grant
397
+ * forge-lock the sender suffix (`inst.*.*.<myOwner>.<myActor>`), so the daemon needn't re-verify a
398
+ * payload sender claim. Any slot may be `*` for allow rules (`inst.*.*.<o>.<a>` to send as me;
399
+ * {@link unicastRecvFilter} for the receive side). */
400
+ export function unicastSubject(space, recipOwner, recipActor, sndOwner, sndActor) {
401
+ return `${spacePrefix(space)}.inst.${ownerToken(recipOwner)}.${ownerToken(recipActor)}.${ownerToken(sndOwner)}.${ownerToken(sndActor)}`;
402
+ }
403
+ /** The receive-side DM filter/grant for a recipient principal: `inst.<owner>.<actor>.>` — every DM
404
+ * addressed to me, from any sender. (The send side is the 4-token {@link unicastSubject}.) */
405
+ export function unicastRecvFilter(space, owner, actor) {
406
+ return `${spacePrefix(space)}.inst.${ownerToken(owner)}.${ownerToken(actor)}.>`;
407
+ }
408
+ /** Anycast: `svc.<service>.<owner>.<actor>` — a service (role), tagged with the sender principal.
409
+ * Subscribers join a queue group so one instance receives. Identity slots accept `*`. */
410
+ export function anycastSubject(space, service, owner, actor) {
411
+ return `${spacePrefix(space)}.svc.${routeToken(service)}.${ownerToken(owner)}.${ownerToken(actor)}`;
412
+ }
413
+ /** The serve-side TASK filter/grant for a role: `svc.<role>.>` — every anycast to the role, from any
414
+ * sender principal (the sender slot widened from one token to two, so the tail is `.>`). */
415
+ export function anycastServeFilter(space, service) {
416
+ return `${spacePrefix(space)}.svc.${routeToken(service)}.>`;
417
+ }
418
+ /** Control request/reply to a service (e.g. the manager): `ctl.<service>.<owner>.<actor>` — tagged with
419
+ * the caller principal; anycast via queue group. Identity slots accept `*` (serve side: `ctl.<tier>.*.*`). */
420
+ export function controlServiceSubject(space, service, owner, actor) {
421
+ return `${spacePrefix(space)}.ctl.${routeToken(service)}.${ownerToken(owner)}.${ownerToken(actor)}`;
171
422
  }
172
423
  /** Control-plane service names — the three-tier split (P2a). The manager subscribes to ALL
173
424
  * three; the cred layer grants {@link CONTROL_SELF_SERVICE} to every agent and
@@ -190,6 +441,13 @@ export const CONTROL_ADMIN = "admin";
190
441
  * agent (only the allow-all manager could reply into the per-id `_INBOX_<id>` prefix). Lifecycle ops
191
442
  * (spawn/stop/despawn) stay on the manager's tiers; durable membership is the daemon's. */
192
443
  export const CONTROL_DELIVERY = "delivery";
444
+ /** The delivery daemon's PRIVILEGED admin rail (the D5 rail-split): control-plane ops the daemon
445
+ * EXECUTES for the mesh's renewal/repair owner — credential reload (`reloadCreds`, the class-2
446
+ * standing-renewal adoption step) now; the live-eviction executor rides here next. Cred-enforced
447
+ * caller set: only the manager's `supervisor` profile holds the request-publish grant (every agent
448
+ * cred is default-denied — nats-server is the boundary, same pattern as {@link CONTROL_ADMIN});
449
+ * the `delivery` cred holds the serve + bounded-reply side. */
450
+ export const CONTROL_DELIVERY_ADMIN = "delivery-admin";
193
451
  export function traceSubject(space, agentId) {
194
452
  return `${spacePrefix(space)}.trace.${token(agentId)}`;
195
453
  }
@@ -206,29 +464,36 @@ export function chatWildcard(space) {
206
464
  return `${spacePrefix(space)}.chat.>`;
207
465
  }
208
466
  /**
209
- * The single authority on the subject layout — every reader of a wire subject goes
210
- * through this, so the sender-position asymmetry lives in exactly one place:
211
- * chat.<sender>.<channel…> sender at [3], channel is everything after
212
- * inst.<target>.<sender> sender at [4]
213
- * svc.<role>.<sender> sender at [4]
214
- * ctl.<service>.<sender> sender at [4]
215
- * Validates the prefix and per-kind shape first and returns `null` on anything else,
216
- * so a malformed subject can never be read as if it carried a sender.
467
+ * The single authority on the subject layout — every reader of a wire subject goes through this, so the
468
+ * owner+actor sender positions live in exactly one place (`kind` stays at [2]):
469
+ * chat.<owner>.<actor>.<channel…> sender owner[3] actor[4], channel is everything after
470
+ * inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor> sender owner[5] actor[6], recipient = [3].[4]
471
+ * svc.<role>.<owner>.<actor> sender owner[4] actor[5], role = [3]
472
+ * ctl.<service>.<owner>.<actor> sender owner[4] actor[5], service = [3]
473
+ * Validates the prefix and per-kind arity first and returns `null` on anything else, so a malformed
474
+ * subject can never be read as if it carried a principal. This SPLITS ONLY (no owner-token validation)
475
+ * — the broker already forge-locked the identity slots via the minted grant; a reader recovers them.
217
476
  */
218
477
  export function parseSubject(subject) {
219
478
  const parts = subject.split(".");
220
479
  if (parts[0] !== ROOT)
221
480
  return null; // cotal.<space>.<kind>.…
222
481
  const kind = parts[2];
482
+ const mk = (kind, owner, actor, rest) => ({ kind, owner, actor, sender: `${owner}.${actor}`, rest });
223
483
  if (kind === "chat") {
224
- if (parts.length < 5)
225
- return null; // cotal.<space>.chat.<sender>.<channel…>
226
- return { kind, sender: parts[3], rest: parts.slice(4).join(".") };
484
+ if (parts.length < 6)
485
+ return null; // cotal.<space>.chat.<owner>.<actor>.<channel…>
486
+ return mk(kind, parts[3], parts[4], parts.slice(5).join("."));
227
487
  }
228
- if (kind === "inst" || kind === "svc" || kind === "ctl") {
229
- if (parts.length !== 5)
230
- return null; // cotal.<space>.<kind>.<route>.<sender>
231
- return { kind, sender: parts[4], rest: parts[3] };
488
+ if (kind === "inst") {
489
+ if (parts.length !== 7)
490
+ return null; // cotal.<space>.inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor>
491
+ return mk(kind, parts[5], parts[6], `${parts[3]}.${parts[4]}`);
492
+ }
493
+ if (kind === "svc" || kind === "ctl") {
494
+ if (parts.length !== 6)
495
+ return null; // cotal.<space>.<kind>.<route>.<owner>.<actor>
496
+ return mk(kind, parts[4], parts[5], parts[3]);
232
497
  }
233
498
  return null;
234
499
  }
@@ -262,21 +527,23 @@ export const CHANNEL_DEFAULTS_KEY = "=defaults";
262
527
  export function membersBucket(space) {
263
528
  return `cotal_members_${token(space)}`;
264
529
  }
265
- /** KV key for one membership record: `<channel>/<owner>`. The channel is concrete (no `*`/`>`,
266
- * validated at the write path) so it is dotted-but-`/`-free, and an owner id is an nkey
267
- * (`[A-Z0-9]`, also `/`-free), so the single `/` separates them unambiguously — both halves
268
- * recover via {@link parseMemberKey}. `/`, `.`, and `[A-Za-z0-9_-]` are all legal KV-key chars
269
- * (`/^[-/=.\w]+$/`), so no encoding is needed. */
270
- export function memberKey(channel, owner) {
271
- return `${channel}/${owner}`;
272
- }
273
- /** Inverse of {@link memberKey}: split a member key back into `{ channel, owner }`, or `null` if
274
- * it isn't one (no `/`). Splits on the single separator channels and owner ids are both `/`-free. */
530
+ /** KV key for one membership record: `<channel>/<principal>`, where `principal` is the member's
531
+ * owner+actor dot-form (`<owner>.<actor>` = {@link principalKey}`.key`) membership is per-agent
532
+ * (owner+actor), not per-owner-shared. The channel is concrete (no `*`/`>`, validated at the write
533
+ * path) so it is dotted-but-`/`-free, and a principal dot-form is `[A-Za-z0-9_.]` (also `/`-free), so
534
+ * the single `/` separates them unambiguously — both halves recover via {@link parseMemberKey}. `/`,
535
+ * `.`, and `[A-Za-z0-9_]` are all legal KV-key chars (`/^[-/=.\w]+$/`), so no encoding is needed. */
536
+ export function memberKey(channel, principal) {
537
+ return `${channel}/${principal}`;
538
+ }
539
+ /** Inverse of {@link memberKey}: split a member key back into `{ channel, principal }`, or `null` if
540
+ * it isn't one (no `/`). Splits on the single separator — channels and principal dot-forms are both
541
+ * `/`-free (the `.` inside a principal is not a `/`). */
275
542
  export function parseMemberKey(key) {
276
543
  const i = key.indexOf("/");
277
544
  if (i <= 0 || i >= key.length - 1)
278
545
  return null;
279
- return { channel: key.slice(0, i), owner: key.slice(i + 1) };
546
+ return { channel: key.slice(0, i), principal: key.slice(i + 1) };
280
547
  }
281
548
  /** Name of the KV bucket holding the durable read-ACL registry (Plane-3) for a space — a
282
549
  * privileged-write sibling of the members/channels buckets. One record per OWNER (key = owner id),
@@ -287,10 +554,12 @@ export function parseMemberKey(key) {
287
554
  export function aclBucket(space) {
288
555
  return `cotal_acl_${token(space)}`;
289
556
  }
290
- /** KV key for one owner's read-ACL record: the owner id (an nkey — `[A-Z0-9]`, `/`-free, a `token()`
291
- * no-op; keyed like presence, which uses the bare id). */
292
- export function aclKey(owner) {
293
- return token(owner);
557
+ /** KV key for one principal's read-ACL record: the member's owner+actor dot-form (`<owner>.<actor>` =
558
+ * {@link principalKey}`.key`) the read ACL is per-agent (owner+actor), like membership. The dot-form
559
+ * is a legal KV key (`/^[-/=.\w]+$/`); pass it through UNCHANGED — do NOT run it through `token()`,
560
+ * which rewrites `.`→`_` and would alias distinct principals onto one key. */
561
+ export function aclKey(principal) {
562
+ return principal;
294
563
  }
295
564
  // ---- Authoritative channel membership (broker CONNZ → derived feed) ----
296
565
  //
@@ -306,10 +575,13 @@ export function aclKey(owner) {
306
575
  export function membershipBucket(space) {
307
576
  return `cotal_membership_${token(space)}`;
308
577
  }
309
- /** KV key for one agent's membership record: the agent id (an nkey — `[A-Z0-9]`, dot-free, a `token()`
310
- * no-op; keyed like presence/acl, which use the bare id). */
311
- export function membershipKey(id) {
312
- return token(id);
578
+ /** KV key for one agent's membership-feed record: the agent's owner+actor dot-form (`<owner>.<actor>` =
579
+ * {@link principalKey}`.key`) one record per agent (owner+actor), keyed like acl/members. Pass the
580
+ * dot-form through UNCHANGED — do NOT run it through `token()` (which rewrites `.`→`_` and would alias
581
+ * distinct principals). Both feed sources (the CONNZ live side and the durable members side) must
582
+ * resolve to this same dot-form so the union does not double-key one agent. */
583
+ export function membershipKey(principal) {
584
+ return principal;
313
585
  }
314
586
  /** Reserved membership-bucket key for the feed's freshness heartbeat — the daemon re-stamps it every
315
587
  * successful poll (even when no membership changed), so the dashboard can tell "feed is live" from "feed
@@ -339,18 +611,30 @@ export function accountConnectSubject(accountId) {
339
611
  export function accountDisconnectSubject(accountId) {
340
612
  return `$SYS.ACCOUNT.${accountId}.DISCONNECT`;
341
613
  }
614
+ /** The per-server client-KICK request subject (`$SYS.REQ.SERVER.<serverID>.KICK`, nats-server
615
+ * `events.go` `clientKickReqSubj`). Payload `{cid}` → the server disconnects that live client by
616
+ * connection id. PER-SERVER: `serverID` MUST come from the SAME CONNZ reply that yielded the cid
617
+ * (KICK is not fan-out; a cid is only meaningful on its own server). System-account only. There is
618
+ * no per-cid success ack — the caller re-scans CONNZ to confirm the connection is gone. This is the
619
+ * kill-live half of revocation; it is ALWAYS paired with a deny-new (ledger revoke / cred expiry /
620
+ * signer strip / ACL removal), since a kicked client reconnects with a fresh cid until its cred dies. */
621
+ export function serverKickSubject(serverId) {
622
+ return `$SYS.REQ.SERVER.${serverId}.KICK`;
623
+ }
342
624
  /** Extract the channel pattern from a live chat SUBSCRIPTION subject in this space, or `null` if it
343
- * isn't one. A subscription's sender slot is `*` (an agent's `sub.allow` is `chat.*.<channel>`), so the
344
- * channel portion (wildcards preserved, e.g. `team.>`) is everything after. This is the ALLOWLIST the
345
- * membership daemon uses to turn a connection's subscription list into its channel-subscription set —
346
- * matched against the chat grammar (not a denylist of known plumbing, which rots when a new plumbing
347
- * subject is added). Drops `_INBOX`, JetStream API, other-space, and non-chat subjects. The whole-chat
348
- * god-view (`chat.*.>` → rest `">"`) IS returned here; the caller excludes such taps separately.
625
+ * isn't one. A subscription's sender slots are `*` (an agent's `sub.allow` is `chat.*.*.<channel>`), so
626
+ * the channel portion (wildcards preserved, e.g. `team.>`) is everything after the two identity tokens.
627
+ * This is the ALLOWLIST the membership daemon uses to turn a connection's subscription list into its
628
+ * channel-subscription set — matched against the chat grammar (not a denylist of known plumbing, which
629
+ * rots when a new plumbing subject is added). Drops `_INBOX`, JetStream API, other-space, and non-chat
630
+ * subjects. The whole-chat god-view (`chat.*.*.>` → rest `">"`) IS returned here; the SHORT-wildcard
631
+ * taps (`chat.>`, `chat.*.>`) fail the ≥6-token arity and return `null` — the caller's god-tap detector
632
+ * must surface those as reads-all separately (a length-checking parse must not let them vanish).
349
633
  *
350
634
  * COUPLING (mitnick): both this extraction AND the membership daemon's god-tap detection ride
351
- * {@link parseSubject}'s sender-at-[3] chat layout. The deferred read-containment grammar reorder
352
- * (sender out of the subject) would move where the channel portion begins and silently break both —
353
- * revisit this + the daemon's exclusion (and their tests) if that grammar ships. */
635
+ * {@link parseSubject}'s owner+actor chat layout (channel after [4]). A future grammar reorder would move
636
+ * where the channel portion begins and silently break both — revisit this + the daemon's exclusion
637
+ * (and their tests) together. */
354
638
  export function channelFromChatSubscription(space, subject) {
355
639
  if (!subject.startsWith(`${spacePrefix(space)}.chat.`))
356
640
  return null;
@@ -424,25 +708,31 @@ export function inboxStream(space) {
424
708
  export function dlvStream(space) {
425
709
  return `DLV_${token(space)}`;
426
710
  }
427
- /** Subject of an owner's mixed durable inbox: `cotal.<space>.dinbox.<owner>` (one per owner). */
428
- export function dinboxSubject(space, owner) {
429
- return `${spacePrefix(space)}.dinbox.${routeToken(owner)}`;
711
+ /** Subject of a principal's mixed durable inbox: `cotal.<space>.dinbox.<owner>.<actor>` (one per agent
712
+ * the fan-out delivers a per-member copy, and a member is a principal). Either identity slot accepts `*`
713
+ * for the daemon's fan-out write grant (`dinbox.*.*`). */
714
+ export function dinboxSubject(space, owner, actor) {
715
+ return `${spacePrefix(space)}.dinbox.${ownerToken(owner)}.${ownerToken(actor)}`;
430
716
  }
431
- /** Subject of an owner's post-auth delivery: `cotal.<space>.dlv.<owner>` (one per owner). */
432
- export function dlvSubject(space, owner) {
433
- return `${spacePrefix(space)}.dlv.${routeToken(owner)}`;
717
+ /** Subject of a principal's post-auth delivery: `cotal.<space>.dlv.<owner>.<actor>` (one per agent). */
718
+ export function dlvSubject(space, owner, actor) {
719
+ return `${spacePrefix(space)}.dlv.${ownerToken(owner)}.${ownerToken(actor)}`;
434
720
  }
435
- /** Parse the owner id out of an owner's mixed-inbox subject `cotal.<space>.dinbox.<owner>`, or null.
436
- * The trusted reader is a SINGLE consumer over `dinbox.>` (all owners), so it recovers the per-message
437
- * owner from the subject (the routing token is `routeToken(owner)` an nkey, a `token()` no-op). */
438
- export function parseDinboxOwner(subject) {
721
+ /** Parse the principal out of a mixed-inbox subject `cotal.<space>.dinbox.<owner>.<actor>`, or null.
722
+ * The trusted reader is a SINGLE consumer over `dinbox.>` (all principals), so it recovers the
723
+ * per-message owner+actor from the subject (split only the broker forge-locked the slots at write). */
724
+ export function parseDinboxPrincipal(subject) {
439
725
  const parts = subject.split(".");
440
- // cotal.<space>.dinbox.<owner>
441
- return parts.length === 4 && parts[0] === ROOT && parts[2] === "dinbox" ? parts[3] : null;
726
+ // cotal.<space>.dinbox.<owner>.<actor>
727
+ return parts.length === 5 && parts[0] === ROOT && parts[2] === "dinbox"
728
+ ? { owner: parts[3], actor: parts[4] }
729
+ : null;
442
730
  }
443
- /** An agent's bind-only per-owner consumer on {@link dlvStream} (filter `dlv.<owner>`). */
444
- export function dlvDurable(owner) {
445
- return `dlv_${token(owner)}`;
731
+ /** An agent's bind-only per-member consumer on {@link dlvStream} (filter `dlv.<owner>.<actor>`). The
732
+ * durable NAME is the principal's JetStream-safe dash-form (`dlv_<owner>-<actor>`) — a `.` is illegal
733
+ * in a durable name, so this uses {@link principalKey}`.name`, never the dot-form. */
734
+ export function dlvDurable(owner, actor) {
735
+ return `dlv_${principalKey(owner, actor).name}`;
446
736
  }
447
737
  /** The single privileged fan-out consumer on the CHAT stream (delivery-daemon-pumped; routing, not
448
738
  * auth). N=1 keeps this exact name (see {@link fanoutDurable}). */
@@ -463,20 +753,21 @@ export function readerDurable(shard = 0, shards = 1) {
463
753
  }
464
754
  /** Name of the REMOVED per-instance chat live-tail durable. Retained only as the canonical name the
465
755
  * read-ACL conformance test asserts an agent can NOT create — it has no live callers, the live read is
466
- * now a native core subscription. */
467
- export function chatDurable(instance) {
468
- return `chat_${token(instance)}`;
469
- }
470
- /** Consumer name for an instance's short-lived chat **history** reads (join-backfill, focus-recall,
471
- * drop-marker). A single per-instance name, scoped to the agent's own id so its create/info/fetch/
472
- * delete grants name-scope to that id a peer can never bind it — while the per-read single
473
- * `filter_subject` is what the create-time ACL pins to `allowSubscribe`. */
474
- export function chatHistDurable(instance) {
475
- return `chathist_${token(instance)}`;
476
- }
477
- /** Durable consumer name for an instance's private DM inbox. */
478
- export function dmDurable(instance) {
479
- return `dm_${token(instance)}`;
756
+ * now a native core subscription. Principal name-form (`chat_<owner>-<actor>`) for namespace consistency. */
757
+ export function chatDurable(owner, actor) {
758
+ return `chat_${principalKey(owner, actor).name}`;
759
+ }
760
+ /** Consumer name for an agent's short-lived chat **history** reads (join-backfill, focus-recall,
761
+ * drop-marker). A single per-agent name in the principal's JetStream-safe dash-form
762
+ * (`chathist_<owner>-<actor>`) so its create/info/fetch/delete grants name-scope to that principal
763
+ * a peer can never bind it — while the per-read single `filter_subject` is what the create-time ACL
764
+ * pins to `allowSubscribe`. */
765
+ export function chatHistDurable(owner, actor) {
766
+ return `chathist_${principalKey(owner, actor).name}`;
767
+ }
768
+ /** Durable consumer name for an agent's private DM inbox — the principal's dash-form (`dm_<owner>-<actor>`). */
769
+ export function dmDurable(owner, actor) {
770
+ return `dm_${principalKey(owner, actor).name}`;
480
771
  }
481
772
  /** Durable consumer name (shared across instances of a role) for the task queue. */
482
773
  export function taskDurable(service) {