@cotal-ai/core 0.10.0 → 0.11.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 (64) hide show
  1. package/dist/agent-file.d.ts +12 -1
  2. package/dist/agent-file.d.ts.map +1 -1
  3. package/dist/agent-file.js +69 -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 +55 -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 +7 -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/provision.js CHANGED
@@ -17,17 +17,30 @@
17
17
  import { join } from "node:path";
18
18
  import { encodeOperator, encodeAccount, encodeUser, fmtCreds, } from "@nats-io/jwt";
19
19
  import { createOperator, createAccount, fromPublic, fromSeed } from "@nats-io/nkeys";
20
- import { token, spacePrefix, chatSubject, assertValidChannel, channelInAllow, unicastSubject, anycastSubject, controlServiceSubject, CONTROL_PRIVILEGED, CONTROL_SELF_SERVICE, CONTROL_ADMIN, CONTROL_DELIVERY, chatStream, dmStream, taskStream, dlvStream, inboxStream, chatHistDurable, dmDurable, taskDurable, dlvDurable, presenceBucket, channelBucket, membersBucket, aclBucket, aclKey, membershipBucket, deliveryBucket, managerBucket, MANAGER_LEASE_KEY, connzRequestSubject, accountConnectSubject, accountDisconnectSubject, MEMBERSHIP_INBOX_PREFIX, FANOUT_DURABLE, INBOX_READER_DURABLE, } from "./subjects.js";
20
+ import { token, spacePrefix, chatSubject, assertValidChannel, channelInAllow, principalKey, parsePrincipalKey, deprovisionTargetPrincipal, principalTags, assertInboxConnId, DEV_OWNER, unicastSubject, anycastSubject, controlServiceSubject, CONTROL_PRIVILEGED, CONTROL_SELF_SERVICE, CONTROL_ADMIN, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, chatStream, dmStream, taskStream, dlvStream, inboxStream, chatHistDurable, dmDurable, taskDurable, dlvDurable, presenceBucket, channelBucket, membersBucket, aclBucket, aclKey, membershipBucket, deliveryBucket, managerBucket, MANAGER_LEASE_KEY, connzRequestSubject, accountConnectSubject, accountDisconnectSubject, MEMBERSHIP_INBOX_PREFIX, FANOUT_DURABLE, INBOX_READER_DURABLE, } from "./subjects.js";
21
+ import { credsClaims } from "./identity.js";
21
22
  const FIVE_MINUTES = 5 * 60;
23
+ /** Bounded lifetime for `standing-renewable` credentials whose renewal owner is ONLINE (D5 slice 5):
24
+ * the holder (or its launcher) re-mints at 75% of the lifetime via the endpoint's creds-source seam,
25
+ * so a copied cred is broker-dead within a day while renewal never involves an operator. 24h keeps
26
+ * the remaining-25% loud-failure window at ~6h — wide enough to notice and repair before expiry. */
27
+ export const STANDING_RENEWABLE_TTL_SEC = 24 * 60 * 60;
28
+ /** Bounded lifetime for the `rotation-renewed` $SYS credentials (membership-observer + connection-
29
+ * evictor). They are NOT online-renewable (the $SYS seed dies at end of `up`), so this exp is the
30
+ * credential-death horizon: a copied observer/evictor cred becomes broker-dead after it, and the
31
+ * operator is expected to have run a coordinated system-account rotation + broker restart within it
32
+ * (the doctor surface warns ahead — slice 6). 30 days balances "copied cred eventually dies" against
33
+ * a comfortable monthly rotation cadence; tune here as one named knob. */
34
+ export const ROTATION_RENEWED_TTL_SEC = 30 * 24 * 60 * 60;
22
35
  /** D5 profile matrix. This is intentionally centralized so every new mint profile must classify its
23
36
  * credential-death behavior instead of silently inheriting non-expiring static creds. */
24
37
  export const CREDENTIAL_LIFETIMES = {
25
38
  agent: { class: "mixed", note: "manager children, foreground spawn/join, and cotal mint static outputs all use this profile; split or repair flow required before default exp" },
26
39
  observer: { class: "static-operator-managed", note: "out-of-band dashboard/audit credential from cotal mint" },
27
40
  admin: { class: "static-operator-managed", note: "out-of-band elevated dashboard/audit credential from cotal mint" },
28
- supervisor: { class: "standing-renewable", renewalOwner: "manager", note: "manager's always-on endpoint; renewal slice required before default exp" },
29
- delivery: { class: "standing-renewable", renewalOwner: "delivery launcher", note: "server-side Plane-3 daemon; renewal slice required before default exp" },
30
- "membership-rw": { class: "standing-renewable", renewalOwner: "delivery launcher", note: "membership feed writer; renewal slice required before default exp" },
41
+ supervisor: { class: "standing-renewable", defaultTtlSeconds: STANDING_RENEWABLE_TTL_SEC, renewalOwner: "manager", note: "manager's always-on endpoint; the manager holds the DATA seed and self-remints via the endpoint creds source (D5 slice 5 class 1)" },
42
+ delivery: { class: "standing-renewable", defaultTtlSeconds: STANDING_RENEWABLE_TTL_SEC, renewalOwner: "manager", note: "server-side Plane-3 daemon; seed-less - the manager re-signs .cotal/delivery.creds for the SAME nkey, requests delivery-admin reloadCreds for explicit adoption, and the endpoint source re-read is only a backstop (D5 slice 5 class 2)" },
43
+ "membership-rw": { class: "standing-renewable", defaultTtlSeconds: STANDING_RENEWABLE_TTL_SEC, renewalOwner: "manager", note: "membership feed writer; seed-less - the manager re-signs .cotal/membership-rw.creds for the SAME nkey, delivery-admin reloadCreds reconnects the rw feed explicitly, and source re-read is only a backstop (D5 slice 5 class 2)" },
31
44
  provisioner: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "setup/spawn provisioning window only" },
32
45
  deprovisioner: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "target-pinned teardown window only" },
33
46
  operator: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "send/dm/join/probe-style operator command" },
@@ -39,11 +52,32 @@ export const CREDENTIAL_LIFETIMES = {
39
52
  "control-caller-privileged": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "ps/start control call" },
40
53
  "control-caller-admin": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "stop/attach admin control call" },
41
54
  deployer: { class: "one-shot", note: "manifest deploy spans planning/launch/ledger; needs near-expiry guard or remint before default exp" },
42
- "membership-observer": { class: "standing-renewable", renewalOwner: "delivery launcher", note: "system-account CONNZ observer persisted beside membership-rw; renewal slice required before default exp" },
55
+ "membership-observer": { class: "rotation-renewed", defaultTtlSeconds: ROTATION_RENEWED_TTL_SEC, renewalOwner: "system-account rotation", note: "$SYS-account CONNZ observer; NOT online-renewable ($SYS seed dies at `up`) - bounded exp, renewed only by rotateSystemAccount + broker restart; doctor warns near expiry" },
56
+ "connection-evictor": { class: "rotation-renewed", defaultTtlSeconds: ROTATION_RENEWED_TTL_SEC, renewalOwner: "system-account rotation", note: "$SYS-account KICK-only live-eviction cred (D5 slice 4); same rotation-renewed posture as the observer" },
43
57
  };
44
58
  export function credentialLifetime(kind) {
45
59
  return CREDENTIAL_LIFETIMES[kind];
46
60
  }
61
+ export function inspectCredHealth(creds, nowSec = Math.floor(Date.now() / 1000)) {
62
+ let claims;
63
+ try {
64
+ claims = credsClaims(creds);
65
+ }
66
+ catch (e) {
67
+ return { state: "unreadable", error: e.message };
68
+ }
69
+ const { iat, exp } = claims;
70
+ if (typeof exp !== "number")
71
+ return { state: "unbounded", iat };
72
+ if (typeof iat !== "number")
73
+ return { state: "unreadable", iat: undefined, exp, error: "user JWT carries exp but no iat - cannot place the renewal point" };
74
+ const renewAt = Math.floor(iat + 0.75 * (exp - iat));
75
+ if (nowSec >= exp)
76
+ return { state: "expired", iat, exp, renewAt };
77
+ if (nowSec >= renewAt)
78
+ return { state: "near-expiry", iat, exp, renewAt };
79
+ return { state: "healthy", iat, exp, renewAt };
80
+ }
47
81
  // Unlimited account limits — without explicit limits a JWT account defaults to 0 conns
48
82
  // (every connect denied). JetStream needs storage on the data account but MUST stay off
49
83
  // the system account (the server refuses to start otherwise).
@@ -140,7 +174,11 @@ export async function createSpaceAuth(space) {
140
174
  sys: { pub: sysPub, jwt: sysJwt, signingSeed: dec(syskp.getSeed()) },
141
175
  };
142
176
  }
143
- function userValidDates(profile, opts) {
177
+ /** Compute a minted credential's `{ exp? }` from an explicit override or the centralized matrix
178
+ * default. Widened to {@link CredentialKind} (not just {@link Profile}) so the bespoke $SYS minters
179
+ * — `membership-observer` / `connection-evictor`, which are kinds, not profiles — thread the same
180
+ * bounded-lifetime policy instead of minting non-expiring $SYS creds. */
181
+ function userValidDates(kind, opts) {
144
182
  if (opts.expiresAt !== undefined && opts.expiresInSeconds !== undefined)
145
183
  throw new Error("mintCreds: pass only one of expiresAt or expiresInSeconds");
146
184
  if (opts.expiresAt !== undefined) {
@@ -148,13 +186,25 @@ function userValidDates(profile, opts) {
148
186
  throw new Error("mintCreds: expiresAt must be a non-negative integer timestamp (seconds)");
149
187
  return { exp: opts.expiresAt };
150
188
  }
151
- const ttl = opts.expiresInSeconds ?? CREDENTIAL_LIFETIMES[profile].defaultTtlSeconds;
189
+ const ttl = opts.expiresInSeconds ?? CREDENTIAL_LIFETIMES[kind].defaultTtlSeconds;
152
190
  if (ttl === undefined)
153
191
  return {};
154
192
  if (!Number.isInteger(ttl) || ttl <= 0)
155
193
  throw new Error("mintCreds: expiresInSeconds must be a positive integer");
156
194
  return { exp: Math.floor(Date.now() / 1000) + ttl };
157
195
  }
196
+ /** Resolve a {@link MintPrincipal} for the STATIC/dev mint path from an {@link Identity} + optional
197
+ * explicit principal. No-login dev default: owner = {@link DEV_OWNER} ("local"), actor = the connection
198
+ * id, so the agent's lane is `local.<id>`. The connection nkey is always the identity's id here (the
199
+ * creds bind to it). User mode does NOT flow through here — the callout mints directly with the
200
+ * server-derived owner + ledger actor. */
201
+ function principalOf(identity, principal) {
202
+ return {
203
+ owner: principal?.owner ?? DEV_OWNER,
204
+ actor: principal?.actor ?? identity.id,
205
+ connId: identity.id,
206
+ };
207
+ }
158
208
  /** Onboard an agent for launch (auth mode): pre-create its bind-only DM (+ Plane-3 DELIVER + role
159
209
  * TASK) durables, RECORD its read ACL in the durable registry (unless `durableMembership:false`), and
160
210
  * mint its scoped creds. Live delivery is the agent's own core subscription — there is no per-instance
@@ -162,6 +212,15 @@ function userValidDates(profile, opts) {
162
212
  * via the server-side delivery daemon's `ctl.delivery` op at connect. A live-only launcher
163
213
  * (`durableMembership:false`, e.g. direct `cotal spawn`) gets no ACL row and stays live-only. */
164
214
  export async function provisionAgent(provisioner, auth, identity, opts = {}) {
215
+ const allowSubscribe = await provisionAgentDurables(provisioner, principalOf(identity, opts.principal), opts);
216
+ return mintCreds(auth, identity, "agent", { ...opts, allowSubscribe });
217
+ }
218
+ /** The DURABLE half of agent onboarding, principal-keyed and credential-agnostic: pre-create the
219
+ * bind-only DM + DELIVER durables, record the read ACL, ensure the role TASK queue. The static
220
+ * path ({@link provisionAgent}) follows it with a mint; the USER-MODE spawn path runs it alone —
221
+ * a user agent's credential is its bearer (callout-minted per connect), never a static cred.
222
+ * Returns the resolved read ACL so both callers scope from the same computed set. */
223
+ export async function provisionAgentDurables(provisioner, pr, opts = {}) {
165
224
  const subscribe = opts.subscribe?.length ? opts.subscribe : ["general"];
166
225
  const allowSubscribe = opts.allowSubscribe?.length ? opts.allowSubscribe : subscribe;
167
226
  // Reject channel names the wire layer would rewrite (the pre-created filter rides token() too).
@@ -173,19 +232,20 @@ export async function provisionAgent(provisioner, auth, identity, opts = {}) {
173
232
  for (const ch of subscribe)
174
233
  if (!channelInAllow(allowSubscribe, ch))
175
234
  throw new Error(`provisionAgent: subscribe "${ch}" is not within allowSubscribe [${allowSubscribe.join(", ")}]`);
176
- await provisioner.provisionDmInbox(identity.id);
177
- await provisioner.provisionDlvInbox(identity.id);
235
+ await provisioner.provisionDmInbox(pr.owner, pr.actor);
236
+ await provisioner.provisionDlvInbox(pr.owner, pr.actor);
178
237
  // Record the agent's read ACL in the durable registry (the same act as baking it into the JWT) so the
179
238
  // server-side delivery daemon can re-authorize this agent's durable entries + validate its runtime
180
239
  // durable-joins — it holds no in-memory ledger. The agent SELF-JOINS its durable boot channels via the
181
240
  // daemon at connect (no manager-written boot membership). `durableMembership:false` (a live-only
182
241
  // launcher, e.g. direct `cotal spawn` with no daemon) opts out of the ACL row → the daemon never
183
242
  // authorizes a durable backstop for it, so it stays live-only.
243
+ // ACL is keyed by the agent's owner+actor principal dot-form (per-agent read authority).
184
244
  if (opts.durableMembership !== false)
185
- await provisioner.commitAcl(identity.id, allowSubscribe);
245
+ await provisioner.commitAcl(principalKey(pr.owner, pr.actor).key, allowSubscribe);
186
246
  if (opts.role)
187
247
  await provisioner.provisionTaskQueue(opts.role);
188
- return mintCreds(auth, identity, "agent", { ...opts, allowSubscribe });
248
+ return allowSubscribe;
189
249
  }
190
250
  /** Mint a user creds file for an agent {@link Identity} (its stable id+seed from
191
251
  * {@link newIdentity}). The account signing key signs over ONLY the public key
@@ -196,9 +256,14 @@ export async function provisionAgent(provisioner, auth, identity, opts = {}) {
196
256
  * enumerated least-privilege — there is no allow-all cred (the former `manager` is deleted). */
197
257
  export async function mintCreds(auth, identity, profile, opts = {}) {
198
258
  const signer = fromSeed(new TextEncoder().encode(auth.account.signingSeed));
199
- const perms = permissionsFor(profile, auth.space, identity.id, opts);
259
+ const pr = principalOf(identity, opts.principal);
260
+ const perms = permissionsFor(profile, auth.space, pr, opts);
200
261
  const validDates = userValidDates(profile, opts);
201
- const userJwt = await encodeUser(profile, fromPublic(identity.id), fromPublic(auth.account.pub), perms, { signer, ...validDates });
262
+ const userJwt = await encodeUser(profile, fromPublic(identity.id), fromPublic(auth.account.pub),
263
+ // Stamp the principal `tags` so this connection's identity is CONNZ-recoverable by the membership
264
+ // feed — the SAME tags the auth callout stamps (user mode), via core's single-source builder. The
265
+ // JWT `name` stays the profile label (a debug breadcrumb; not a surfaced/queryable CONNZ field).
266
+ { ...perms, tags: principalTags(pr.owner, pr.actor) }, { signer, ...validDates });
202
267
  const creds = fmtCreds(userJwt, fromSeed(new TextEncoder().encode(identity.seed)));
203
268
  return new TextDecoder().decode(creds);
204
269
  }
@@ -206,47 +271,63 @@ export async function mintCreds(auth, identity, profile, opts = {}) {
206
271
  * exactly what each profile does. Every profile is now enumerated least-privilege — the former
207
272
  * allow-all `manager` is gone (its roles split across supervisor/provisioner/operator/purger and the
208
273
  * PR 1.5 CLI-surface profiles). Subject/stream/durable names come from the shared builders so the ACLs
209
- * can't drift from the wire layout. */
210
- function permissionsFor(profile, space, id, opts) {
274
+ * can't drift from the wire layout.
275
+ *
276
+ * PRINCIPAL-PARAMETERIZED + MODE-AGNOSTIC (owner+actor grammar): `pr` carries the owner+actor wire
277
+ * principal (chat/inst/svc/ctl subjects, per-agent durables, the presence key all scope to it) PLUS the
278
+ * connection nkey (`pr.connId`, which scopes the private reply inbox `_INBOX_<connId>`). Core does NOT
279
+ * fork on dev-vs-user — the composition root supplies the principal: the auth callout passes the derived
280
+ * owner + ledger actor + the per-connection ephemeral nkey; the static/dev mint passes
281
+ * `{owner:"local", actor:<id>, connId:<id>}` via {@link principalOf}. EXPORTED so the callout's injected
282
+ * `permissionsFor` hook can feed a validated principal straight into the same builder. */
283
+ export function permissionsFor(profile, space, pr, opts) {
284
+ // Guard the connId BEFORE any profile builds `_INBOX_<connId>.>`: in user mode connId is a client-
285
+ // chosen nonce (untrusted), so a metacharacter here would escalate the inbox grant to every inbox.
286
+ // Assert once, for all profiles (each early-returning profile builds its own inbox from pr.connId).
287
+ assertInboxConnId(pr.connId);
211
288
  if (profile === "delivery")
212
- return deliveryPermissions(space, id); // scoped server-side Plane-3 infra
289
+ return deliveryPermissions(space, pr); // scoped server-side Plane-3 infra
213
290
  if (profile === "membership-rw")
214
- return membershipRwPermissions(space, id); // scoped graph-feed reader/writer
291
+ return membershipRwPermissions(space, pr); // scoped graph-feed reader/writer
215
292
  if (profile === "supervisor")
216
- return supervisorPermissions(space, id); // always-on daemon (closure (ii) gate)
293
+ return supervisorPermissions(space, pr); // always-on daemon (closure (ii) gate)
217
294
  if (profile === "provisioner")
218
- return provisionerPermissions(space, id); // ephemeral onboarding authority (closure (ii))
295
+ return provisionerPermissions(space, pr); // ephemeral onboarding authority (closure (ii))
219
296
  if (profile === "deprovisioner") {
220
- // Ephemeral, TARGET-PINNED teardown (#159 B) — the counterpart to `provisioner`. It deletes exactly
221
- // ONE departed agent's id-keyed footprint, so the target id is REQUIRED and baked into the grants.
297
+ // Ephemeral, TARGET-PINNED teardown (#159 B) — the counterpart to `provisioner`. The target is a
298
+ // full principal dot-form for user-mode agents, or a bare static/dev actor id (keyed under
299
+ // DEV_OWNER) — see {@link deprovisionTargetPrincipal}.
222
300
  if (!opts.deprovisionTarget)
223
- throw new Error("permissionsFor: deprovisioner requires opts.deprovisionTarget (the departed agent's id)");
224
- return deprovisionerPermissions(space, id, opts.deprovisionTarget);
301
+ throw new Error("permissionsFor: deprovisioner requires opts.deprovisionTarget (the departed agent's actor id)");
302
+ return deprovisionerPermissions(space, pr, opts.deprovisionTarget);
225
303
  }
226
304
  if (profile === "purger")
227
- return purgerPermissions(space, id); // ephemeral history-purge (closure (ii))
305
+ return purgerPermissions(space, pr); // ephemeral history-purge (closure (ii))
228
306
  if (profile === "operator")
229
- return operatorPermissions(space, id); // human-CLI client (send/dm/ask) (closure (ii))
307
+ return operatorPermissions(space, pr); // human-CLI client (send/dm/ask) (closure (ii))
230
308
  if (profile === "probe")
231
- return probePermissions(id); // connect-only liveness/auth preflight (PR 1.5)
309
+ return probePermissions(pr); // connect-only liveness/auth preflight (PR 1.5)
232
310
  if (profile === "channel-writer")
233
- return channelWriterPermissions(space, id); // channel-registry writes (PR 1.5)
311
+ return channelWriterPermissions(space, pr); // channel-registry writes (PR 1.5)
234
312
  if (profile === "channel-purger")
235
- return channelPurgerPermissions(space, id); // channel-writer + CHAT purge (PR 1.5)
313
+ return channelPurgerPermissions(space, pr); // channel-writer + CHAT purge (PR 1.5)
236
314
  if (profile === "teardown")
237
- return teardownPermissions(space, id); // sole STREAM.DELETE holder (PR 1.5)
315
+ return teardownPermissions(space, pr); // sole STREAM.DELETE holder (PR 1.5)
238
316
  if (profile === "control-caller-privileged")
239
- return controlCallerPermissions(space, id, CONTROL_PRIVILEGED); // ps/start (PR 1.5)
317
+ return controlCallerPermissions(space, pr, CONTROL_PRIVILEGED); // ps/start (PR 1.5)
240
318
  if (profile === "control-caller-admin")
241
- return controlCallerPermissions(space, id, CONTROL_ADMIN); // stop/attach (PR 1.5)
319
+ return controlCallerPermissions(space, pr, CONTROL_ADMIN); // stop/attach (PR 1.5)
242
320
  if (profile === "deployer")
243
- return deployerPermissions(space, id); // spawn -f deploy authority (PR 1.5)
321
+ return deployerPermissions(space, pr, opts.controlTier ?? CONTROL_ADMIN); // spawn -f deploy authority (PR 1.5; user-mode view rides privileged)
244
322
  const CHAT = chatStream(space), DM = dmStream(space), TASK = taskStream(space);
245
323
  const KV = `KV_${presenceBucket(space)}`;
246
324
  const CHKV = `KV_${channelBucket(space)}`; // channel registry (read-only for everyone)
247
325
  const MEMKV = `KV_${membershipBucket(space)}`; // derived graph membership feed (read-only — dashboard)
248
326
  const DLVKV = `KV_${deliveryBucket(space)}`; // delivery lease/readiness (read-only — Component 6 health)
249
- const inbox = `_INBOX_${id}.>`;
327
+ // Wire identity: owner+actor for subjects/durables/presence (dot-form `pk.key`, name-form `pk.name`);
328
+ // the reply inbox keys on the CONNECTION nkey, not the principal (see MintPrincipal).
329
+ const pk = principalKey(pr.owner, pr.actor);
330
+ const inbox = `_INBOX_${pr.connId}.>`;
250
331
  if (profile === "observer" || profile === "admin") {
251
332
  // Read-only: live feed via tap, history + presence via ephemeral/ordered consumers it
252
333
  // creates on CHAT + the presence KV. No chat/inst/svc/ctl publish → can't post.
@@ -303,7 +384,7 @@ function permissionsFor(profile, space, id, opts) {
303
384
  // stale/unwired profile string (e.g. a JS caller bypassing the closed `Profile` union). Fail loud rather
304
385
  // than mint it agent perms by accident (the no-fallbacks rule; matches the deleted `manager`'s intent).
305
386
  if (profile !== "agent")
306
- throw new Error(`permissionsFor: unhandled profile "${profile}" add an explicit arm, do not fall through to agent`);
387
+ throw new Error(`permissionsFor: unhandled profile "${profile}" - add an explicit arm, do not fall through to agent`);
307
388
  const allowPublish = opts.allowPublish ?? []; // post ACL — DEFAULT-DENY (publish must be declared)
308
389
  const allowSubscribe = opts.allowSubscribe?.length ? opts.allowSubscribe : ["general"]; // read ACL
309
390
  // Re-assert at the mint chokepoint (covers mint/spawn paths that bypass the file loader): a policy
@@ -311,20 +392,20 @@ function permissionsFor(profile, space, id, opts) {
311
392
  for (const ch of [...allowSubscribe, ...allowPublish])
312
393
  assertValidChannel(ch);
313
394
  const manager = opts.manager ?? CONTROL_PRIVILEGED;
314
- const chatHistD = chatHistDurable(id), dmD = dmDurable(id);
315
- const DLV = dlvStream(space), dlvD = dlvDurable(id); // Plane-3 per-member delivery (bind-only)
395
+ const chatHistD = chatHistDurable(pr.owner, pr.actor), dmD = dmDurable(pr.owner, pr.actor);
396
+ const DLV = dlvStream(space), dlvD = dlvDurable(pr.owner, pr.actor); // Plane-3 per-member delivery (bind-only)
316
397
  const svcD = opts.role ? taskDurable(opts.role) : undefined;
317
398
  const pubAllow = [
318
- // peer publish — identity + channel scope, built from the real builders. Default-deny: ONLY the
319
- // declared allowPublish channels (none by default) get a chat-publish grant.
320
- ...allowPublish.map((ch) => chatSubject(space, id, ch)),
321
- unicastSubject(space, "*", id), // inst.*.<id> — DM any instance, as me
322
- anycastSubject(space, "*", id), // svc.*.<id> — anycast any role, as me
323
- controlServiceSubject(space, CONTROL_SELF_SERVICE, id), // ctl.self.<id> — self stop/despawn, granted to all
324
- // ctl.delivery.<id> — request a durable backstop join/leave/list from the SERVER-SIDE delivery
325
- // daemon (NOT the manager). The reply rides this same subtree (`ctl.delivery.<id>.reply.<n>`, in
399
+ // peer publish — owner+actor identity + channel scope, built from the real builders. Default-deny:
400
+ // ONLY the declared allowPublish channels (none by default) get a chat-publish grant.
401
+ ...allowPublish.map((ch) => chatSubject(space, pr.owner, pr.actor, ch)),
402
+ unicastSubject(space, "*", "*", pr.owner, pr.actor), // inst.*.*.<o>.<a> — DM any instance, as me
403
+ anycastSubject(space, "*", pr.owner, pr.actor), // svc.*.<o>.<a> — anycast any role, as me
404
+ controlServiceSubject(space, CONTROL_SELF_SERVICE, pr.owner, pr.actor), // ctl.self.<o>.<a> — self stop/despawn
405
+ // ctl.delivery.<o>.<a> — request a durable backstop join/leave/list from the SERVER-SIDE delivery
406
+ // daemon (NOT the manager). The reply rides this same subtree (`ctl.delivery.<o>.<a>.reply.<n>`, in
326
407
  // sub.allow below) so the daemon can answer without broad inbox-publish — see CONTROL_DELIVERY.
327
- controlServiceSubject(space, CONTROL_DELIVERY, id),
408
+ controlServiceSubject(space, CONTROL_DELIVERY, pr.owner, pr.actor),
328
409
  // JetStream control plane — scoped to this agent's own streams/durables.
329
410
  "$JS.API.INFO",
330
411
  // STREAM.INFO: CHAT (join watermark, recall drop-marker, channel-list counts — a documented
@@ -343,7 +424,7 @@ function permissionsFor(profile, space, id, opts) {
343
424
  // old unfiltered DIRECT.GET.<CHAT> (which could fetch ANY message regardless of channel). The
344
425
  // name is the agent's own, so info/fetch/delete can't reach a peer's consumer. NO broad
345
426
  // CONSUMER.CREATE.<CHAT> / .> deny here: NATS deny beats allow, which would also kill these.
346
- ...allowSubscribe.map((ch) => `$JS.API.CONSUMER.CREATE.${CHAT}.${chatHistD}.${chatSubject(space, "*", ch)}`),
427
+ ...allowSubscribe.map((ch) => `$JS.API.CONSUMER.CREATE.${CHAT}.${chatHistD}.${chatSubject(space, "*", "*", ch)}`),
347
428
  `$JS.API.CONSUMER.INFO.${CHAT}.${chatHistD}`,
348
429
  `$JS.API.CONSUMER.MSG.NEXT.${CHAT}.${chatHistD}`,
349
430
  `$JS.API.CONSUMER.DELETE.${CHAT}.${chatHistD}`,
@@ -362,7 +443,7 @@ function permissionsFor(profile, space, id, opts) {
362
443
  `$JS.API.CONSUMER.CREATE.${KV}.>`,
363
444
  `$JS.API.CONSUMER.INFO.${KV}.>`,
364
445
  "$JS.FC.>",
365
- `$KV.${presenceBucket(space)}.${id}`, // own presence key only — can't spoof peers
446
+ `$KV.${presenceBucket(space)}.${pk.key}`, // own presence key (owner+actor) only — can't spoof peers
366
447
  // Channel registry: read-only (watch + direct kv.get for the join-time replay decision).
367
448
  // No `$KV.${channelBucket(space)}.*` publish — privileged-write, default-deny gives that free.
368
449
  `$JS.API.STREAM.MSG.GET.${CHKV}`,
@@ -391,7 +472,13 @@ function permissionsFor(profile, space, id, opts) {
391
472
  // allow-list, so nats-server rejects the publish — no handler check, no deny-entry (a
392
473
  // blanket `ctl.<mgr>.>` deny would override this grant too, since NATS deny beats allow).
393
474
  // The self-service subject above is granted to all regardless of capability.
394
- pubAllow.push(controlServiceSubject(space, manager, id));
475
+ pubAllow.push(controlServiceSubject(space, manager, pr.owner, pr.actor));
476
+ }
477
+ if (opts.capabilities?.includes("admin")) {
478
+ // Admin capability → the ADMIN control tier (cross-agent stop/attach, manifest launch). In user
479
+ // mode this arrives via the ledger row's scope (`cotal actor grant … --scope admin`) — the
480
+ // broker-enforced half of the tier split; the manager's per-op checks stay on top of it.
481
+ pubAllow.push(controlServiceSubject(space, CONTROL_ADMIN, pr.owner, pr.actor));
395
482
  }
396
483
  // Explicit create-deny (defense-in-depth over default-deny) on the two streams whose
397
484
  // create-time filter_subject is the attack surface — DM (private content) and TASK
@@ -416,18 +503,20 @@ function permissionsFor(profile, space, id, opts) {
416
503
  // with NO manager: join = nc.subscribe, broker-enforced per-subscribe, no consumer name to confine,
417
504
  // so an open ACL needs no enumeration. This sub.allow grant IS the live read path — there is no
418
505
  // per-instance chat durable; the durable backstop is Plane-3 (delivery-daemon fan-out → per-member DELIVER).
419
- const subChat = allowSubscribe.map((ch) => chatSubject(space, "*", ch));
420
- // Replies to this agent's durable join/leave/list requests ride `ctl.delivery.<id>.>` (NOT the
506
+ const subChat = allowSubscribe.map((ch) => chatSubject(space, "*", "*", ch));
507
+ // Replies to this agent's durable join/leave/list requests ride `ctl.delivery.<o>.<a>.>` (NOT the
421
508
  // per-id _INBOX), so the scoped delivery daemon can answer without broad inbox-publish.
422
- const deliveryReplies = `${controlServiceSubject(space, CONTROL_DELIVERY, id)}.>`;
509
+ const deliveryReplies = `${controlServiceSubject(space, CONTROL_DELIVERY, pr.owner, pr.actor)}.>`;
423
510
  // Bounded control replies (closure (i)): the manager's lifecycle tiers now reply on
424
511
  // `ctl.<tier>.<id>.reply.>` (not the per-id `_INBOX`), so each agent must subscribe the reply subtree
425
- // for the tiers it may call. Every agent can self-stop ⇒ always grant the self tier; the privileged
426
- // tier's reply is granted only with the spawn capability (which also grants the request publish above).
427
- // Admin is manager-only — agents never call it, so no admin reply sub.
428
- const controlReplies = [`${controlServiceSubject(space, CONTROL_SELF_SERVICE, id)}.reply.>`];
512
+ // for the tiers it may call. Every agent can self-stop ⇒ always grant the self tier; the privileged /
513
+ // admin tiers' replies are granted only with the matching capability (which also grants the request
514
+ // publish above).
515
+ const controlReplies = [`${controlServiceSubject(space, CONTROL_SELF_SERVICE, pr.owner, pr.actor)}.reply.>`];
429
516
  if (opts.capabilities?.includes("spawn"))
430
- controlReplies.push(`${controlServiceSubject(space, CONTROL_PRIVILEGED, id)}.reply.>`);
517
+ controlReplies.push(`${controlServiceSubject(space, CONTROL_PRIVILEGED, pr.owner, pr.actor)}.reply.>`);
518
+ if (opts.capabilities?.includes("admin"))
519
+ controlReplies.push(`${controlServiceSubject(space, CONTROL_ADMIN, pr.owner, pr.actor)}.reply.>`);
431
520
  return { pub: { allow: pubAllow, deny: pubDeny }, sub: { allow: [inbox, deliveryReplies, ...controlReplies, ...subChat] } };
432
521
  }
433
522
  /** The long-lived SUPERVISOR permission set (closure (ii), residual 2) — the always-on manager daemon
@@ -443,14 +532,14 @@ function permissionsFor(profile, space, id, opts) {
443
532
  * `$JS` is an ENUMERATED allow-list — exactly the presence-watch + lease-KV verbs — never `$JS.>`. A
444
533
  * leaked supervisor cred can hold/serve control and read the public roster; it cannot read a DM, forge an
445
534
  * actor, provision, purge, or tamper with a stream. */
446
- function supervisorPermissions(space, id) {
535
+ function supervisorPermissions(space, pr) {
447
536
  const PKV = `KV_${presenceBucket(space)}`, MKV = `KV_${managerBucket(space)}`;
448
- // The three SERVED lifecycle tiers (manager.ts serveControl): subscribe `ctl.<tier>.*` (queue-grouped)
449
- // and reply on the bounded `ctl.<tier>.<caller>.reply.<uuid>` subtree. Plain NATS request/reply no
450
- // `$JS.ACK` for control replies (panel blocker #6).
537
+ // The three SERVED lifecycle tiers (manager.ts serveControl): subscribe `ctl.<tier>.*.*` (queue-grouped,
538
+ // the owner+actor caller slots widened from one token to two) and reply on the bounded
539
+ // `ctl.<tier>.<owner>.<actor>.reply.<uuid>` subtree. Plain NATS request/reply no `$JS.ACK`.
451
540
  const tiers = [CONTROL_PRIVILEGED, CONTROL_SELF_SERVICE, CONTROL_ADMIN];
452
- const ctlServe = tiers.map((t) => controlServiceSubject(space, t, "*")); // ctl.<tier>.*
453
- const ctlReplies = tiers.map((t) => `${controlServiceSubject(space, t, "*")}.reply.>`);
541
+ const ctlServe = tiers.map((t) => controlServiceSubject(space, t, "*", "*")); // ctl.<tier>.*.*
542
+ const ctlReplies = tiers.map((t) => `${controlServiceSubject(space, t, "*", "*")}.reply.>`);
454
543
  return {
455
544
  pub: {
456
545
  allow: [
@@ -463,20 +552,26 @@ function supervisorPermissions(space, id) {
463
552
  // Presence: publish OWN key + watch the roster. Own key only (no peer-key forge — residual 3); no
464
553
  // presence-stream purge/delete (no force-offline tamper). No presence kv.get (roster is the in-memory
465
554
  // watch cache + sweep), so no STREAM.MSG.GET on presence.
466
- `$KV.${presenceBucket(space)}.${id}`,
555
+ `$KV.${presenceBucket(space)}.${principalKey(pr.owner, pr.actor).key}`,
467
556
  `$JS.API.STREAM.INFO.${PKV}`,
468
557
  `$JS.API.CONSUMER.CREATE.${PKV}.>`, // kv.watch ordered consumer (roster)
469
558
  `$JS.API.CONSUMER.INFO.${PKV}.>`,
470
559
  "$JS.FC.>", // ordered-consumer flow control
471
560
  // Control: reply to any caller on each SERVED tier (bounded). It SERVES (does not call), so no
472
- // request-publish grant and no position-1 wildcard.
561
+ // request-publish grant and no position-1 wildcard — EXCEPT the delivery-admin rail below.
473
562
  ...ctlReplies,
563
+ // The ONE control service the supervisor CALLS (D5 slice 5): the delivery daemon's privileged
564
+ // admin rail — the manager is the class-2 renewal owner, and after re-signing the daemon creds
565
+ // files it requests `reloadCreds` here so adoption is an explicit, auditable event. Self-scoped
566
+ // request subject (its own owner+actor slots), bounded reply subtree in sub.allow below.
567
+ controlServiceSubject(space, CONTROL_DELIVERY_ADMIN, pr.owner, pr.actor),
474
568
  ],
475
569
  },
476
570
  sub: {
477
- // Own reply inbox + the three served control tiers (queue-grouped). NO chat/inst/dlv native sub (the
478
- // supervisor reads no feed), NO broad `$JS.>`/`$KV.>` (the residual-2 read/admin path is gone).
479
- allow: [`_INBOX_${id}.>`, ...ctlServe],
571
+ // Own reply inbox + the three served control tiers (queue-grouped) + the delivery-admin reply
572
+ // subtree for its OWN requests. NO chat/inst/dlv native sub (the supervisor reads no feed), NO
573
+ // broad `$JS.>`/`$KV.>` (the residual-2 read/admin path is gone).
574
+ allow: [`_INBOX_${pr.connId}.>`, ...ctlServe, `${controlServiceSubject(space, CONTROL_DELIVERY_ADMIN, pr.owner, pr.actor)}.>`],
480
575
  },
481
576
  };
482
577
  }
@@ -489,17 +584,17 @@ function supervisorPermissions(space, id) {
489
584
  * provisioning. A leaked operator cred can post as itself and read the roster — the same surface as the
490
585
  * human who ran the command. (The interactive `cotal join` console — chat read + own-DM receive — is a
491
586
  * separate, fuller surface, deferred: it needs the unprovisioned-console DM self-create fixed first.) */
492
- function operatorPermissions(space, id) {
587
+ function operatorPermissions(space, pr) {
493
588
  const PKV = `KV_${presenceBucket(space)}`, CHKV = `KV_${channelBucket(space)}`;
494
589
  return {
495
590
  pub: {
496
591
  allow: [
497
- // Post AS itself only — self-scoped, so a leaked operator cred can never forge a message
498
- // attributable to another actor.
499
- chatSubject(space, id, ">"), // chat.<id>.> — multicast any channel as me
500
- unicastSubject(space, "*", id), // inst.*.<id> — DM any peer as me
501
- anycastSubject(space, "*", id), // svc.*.<id> — anycast any role as me
502
- `$KV.${presenceBucket(space)}.${id}`, // own presence key (when a caller registers; own key only)
592
+ // Post AS itself only — self-scoped (owner+actor), so a leaked operator cred can never forge a
593
+ // message attributable to another principal.
594
+ chatSubject(space, pr.owner, pr.actor, ">"), // chat.<o>.<a>.> — multicast any channel as me
595
+ unicastSubject(space, "*", "*", pr.owner, pr.actor), // inst.*.*.<o>.<a> — DM any peer as me
596
+ anycastSubject(space, "*", pr.owner, pr.actor), // svc.*.<o>.<a> — anycast any role as me
597
+ `$KV.${presenceBucket(space)}.${principalKey(pr.owner, pr.actor).key}`, // own presence key only
503
598
  "$JS.API.INFO",
504
599
  // Presence watch (name→id resolution + the live roster) — read-only ordered consumer. No
505
600
  // STREAM.MSG.GET (the roster is the in-memory watch cache).
@@ -520,7 +615,7 @@ function operatorPermissions(space, id) {
520
615
  },
521
616
  // Own reply inbox only (presence/channel watch ordered-consumer delivery + any request replies land
522
617
  // here). NO chat/inst/dlv native sub — the operator posts and reads the roster, it receives no feed.
523
- sub: { allow: [`_INBOX_${id}.>`] },
618
+ sub: { allow: [`_INBOX_${pr.connId}.>`] },
524
619
  };
525
620
  }
526
621
  /** Connect-only PROBE (PR 1.5) — the liveness/auth preflight (`preflight.ts preflightTarget`, minted on
@@ -528,14 +623,14 @@ function operatorPermissions(space, id) {
528
623
  * and the creds are accepted, then closes it — it performs NO pub/sub. So the tightest possible grant:
529
624
  * deny ALL publish, subscribe only to the own reply inbox. A leaked probe cred can open a socket and do
530
625
  * nothing else. (Was the broad `manager` cred — minted on nearly every command, the worst over-grant.) */
531
- function probePermissions(id) {
532
- return { pub: { deny: [">"] }, sub: { allow: [`_INBOX_${id}.>`] } };
626
+ function probePermissions(pr) {
627
+ return { pub: { deny: [">"] }, sub: { allow: [`_INBOX_${pr.connId}.>`] } };
533
628
  }
534
629
  /** CHANNEL-WRITER (PR 1.5) — edits the channel registry ONLY: `cotal channels set/default` and the
535
630
  * `spawn -f` new-channel seed (`seedChannelRegistry`). It VALUE-writes `$KV.<channelBucket>` (a channel's
536
631
  * config key) and read-before-writes it. NO stream data, NO other bucket, NO chat/DM — a leaked
537
632
  * channel-writer can only rewrite channel config, never post, read a body, or tear a stream down. */
538
- function channelWriterPermissions(space, id) {
633
+ function channelWriterPermissions(space, pr) {
539
634
  const CHKV = `KV_${channelBucket(space)}`;
540
635
  return {
541
636
  pub: {
@@ -549,14 +644,14 @@ function channelWriterPermissions(space, id) {
549
644
  `$JS.API.DIRECT.GET.${CHKV}.>`,
550
645
  ],
551
646
  },
552
- sub: { allow: [`_INBOX_${id}.>`] },
647
+ sub: { allow: [`_INBOX_${pr.connId}.>`] },
553
648
  };
554
649
  }
555
650
  /** CHANNEL-PURGER (PR 1.5) — the `cotal web` dashboard's ONLY write path: delete a channel
556
651
  * (`clearChannel` = filtered `STREAM.PURGE.CHAT` to drop the channel's messages + a `$KV.<channelBucket>`
557
652
  * key delete). Pre-minted once by `web` so the account signing seed falls out of scope; the dashboard's
558
653
  * READ side runs on the separate read-only `admin` cred. = channel-writer + the scoped CHAT purge. */
559
- function channelPurgerPermissions(space, id) {
654
+ function channelPurgerPermissions(space, pr) {
560
655
  const CHKV = `KV_${channelBucket(space)}`;
561
656
  // `clearChannel` only kvm.OPENs the (already-created) bucket, key-deletes, and purges — it never
562
657
  // kvm.creates, so — unlike channel-writer's set/default back-compat path — this cred gets NO
@@ -572,7 +667,7 @@ function channelPurgerPermissions(space, id) {
572
667
  `$JS.API.STREAM.PURGE.${chatStream(space)}`, // drop the channel's chat messages
573
668
  ],
574
669
  },
575
- sub: { allow: [`_INBOX_${id}.>`] },
670
+ sub: { allow: [`_INBOX_${pr.connId}.>`] },
576
671
  };
577
672
  }
578
673
  /** TEARDOWN (PR 1.5) — `cotal down -f` space teardown. The SOLE cred that keeps `STREAM.DELETE` (the
@@ -583,7 +678,7 @@ function channelPurgerPermissions(space, id) {
583
678
  * DM/DLV body, posts chat, or forges. Isolated here so no standing operator/provisioner/supervisor cred
584
679
  * can delete a stream; a leaked teardown can wipe a space you own + stop its agents (that IS its job),
585
680
  * nothing else. Minted ephemerally per teardown from the local trust material (same-checkout `down -f`). */
586
- function teardownPermissions(space, id) {
681
+ function teardownPermissions(space, pr) {
587
682
  const CHAT = chatStream(space);
588
683
  const PKV = `KV_${presenceBucket(space)}`, CHKV = `KV_${channelBucket(space)}`;
589
684
  // deleteSpace() deletes EVERY stream + KV bucket setup creates (5 streams + 7 buckets); each needs
@@ -606,7 +701,7 @@ function teardownPermissions(space, id) {
606
701
  `$JS.API.CONSUMER.INFO.${CHKV}.>`,
607
702
  "$JS.FC.>", // ordered-consumer flow control
608
703
  // Stop the managed agents via the admin control tier (ps + per-agent stop).
609
- controlServiceSubject(space, CONTROL_ADMIN, id),
704
+ controlServiceSubject(space, CONTROL_ADMIN, pr.owner, pr.actor),
610
705
  ...del,
611
706
  // deleteChannels/clearChannel: purge the channel's chat messages + delete its registry key.
612
707
  `$JS.API.STREAM.PURGE.${CHAT}`,
@@ -617,7 +712,7 @@ function teardownPermissions(space, id) {
617
712
  // subtree: the agent-stop step is `requestControl(CONTROL_ADMIN, ps/stop)`, whose reply rides
618
713
  // `ctl.admin.<id>.reply.<uuid>` (NOT `_INBOX`) — without this grant those calls hang and the agents are
619
714
  // never stopped before the streams are deleted.
620
- sub: { allow: [`_INBOX_${id}.>`, `${controlServiceSubject(space, CONTROL_ADMIN, id)}.reply.>`] },
715
+ sub: { allow: [`_INBOX_${pr.connId}.>`, `${controlServiceSubject(space, CONTROL_ADMIN, pr.owner, pr.actor)}.reply.>`] },
621
716
  };
622
717
  }
623
718
  /** CONTROL-CALLER (PR 1.5) — the operator's lifecycle commands (`cotal ps/start/stop/attach`,
@@ -634,15 +729,15 @@ function teardownPermissions(space, id) {
634
729
  * • `control-caller-admin` (stop/attach) gets ONLY `ctl.<admin>.<id>` — it genuinely needs cross-agent
635
730
  * reach. Its containment is NOT a manager re-check (there is none): it is the broker gating the admin
636
731
  * subject + the cred being ephemeral (mint → one request → disconnect, from the local signing seed). */
637
- function controlCallerPermissions(space, id, tier) {
638
- const reqSubject = controlServiceSubject(space, tier, id);
732
+ function controlCallerPermissions(space, pr, tier) {
733
+ const reqSubject = controlServiceSubject(space, tier, pr.owner, pr.actor);
639
734
  return {
640
735
  pub: { allow: [reqSubject] }, // exactly ONE tier — ps/start XOR stop/attach
641
736
  // Own inbox + the BOUNDED control-reply subtree. `requestControl` issues a `noMux` request whose reply
642
737
  // rides `ctl.<tier>.<id>.reply.<uuid>` (UNDER its own request subject, NOT `_INBOX`), so it must be able
643
738
  // to subscribe that subtree — without this grant the reply sub is broker-denied and every control call
644
739
  // hangs to timeout (endpoint.ts:803-806 predicts exactly this).
645
- sub: { allow: [`_INBOX_${id}.>`, `${reqSubject}.reply.>`] },
740
+ sub: { allow: [`_INBOX_${pr.connId}.>`, `${reqSubject}.reply.>`] },
646
741
  };
647
742
  }
648
743
  /** DEPLOYER (PR 1.5) — the `cotal spawn -f` manifest-deploy authority. `spawn -f` drives ONE
@@ -663,7 +758,7 @@ function controlCallerPermissions(space, id, tier) {
663
758
  * boundary. Containment is therefore the LIFETIME, not a manager re-check: minted from LOCAL same-checkout
664
759
  * auth for one `spawn -f`, memory-only, dropped after deploy. If it is ever persisted, handed to
665
760
  * user-supplied `--creds`, or reused as a general "read + admin" cred, revisit. */
666
- function deployerPermissions(space, id) {
761
+ function deployerPermissions(space, pr, tier = CONTROL_ADMIN) {
667
762
  const PKV = `KV_${presenceBucket(space)}`, CHKV = `KV_${channelBucket(space)}`;
668
763
  const MSHIP = `KV_${membershipBucket(space)}`, MGRKV = `KV_${managerBucket(space)}`;
669
764
  // Read verbs for a KV bucket SCANNED/WATCHED via an ordered consumer (presence, channel registry, and
@@ -692,14 +787,16 @@ function deployerPermissions(space, id) {
692
787
  ...kvScan(MSHIP), // membership FEED read (readMembership → detectUnmanagedActors) — the membership_ bucket
693
788
  ...kvPointRead(MGRKV), // manager-singleton lease keyed read (waitManagerReady) — point-get, NO write, NO watch
694
789
  "$JS.FC.>", // ordered-consumer flow control
695
- // Admin control tier ONLY — launch + ps readiness (both CONTROL_ADMIN). No privileged subject.
696
- controlServiceSubject(space, CONTROL_ADMIN, id),
790
+ // ONE control tier — launch + ps readiness. Static operator deploy creds ride CONTROL_ADMIN
791
+ // (the historical shape); the user-mode `deployer` VIEW rides CONTROL_PRIVILEGED so the
792
+ // manager's owner-equality launch authorization governs (never the admin-tier bypass).
793
+ controlServiceSubject(space, tier, pr.owner, pr.actor),
697
794
  ],
698
795
  },
699
- // Own inbox (presence/registry watch delivery + JS API responses) + the BOUNDED admin control-reply
700
- // subtree: `requestControl(CONTROL_ADMIN, launch/ps)` subscribes `ctl.admin.<id>.reply.<uuid>`, so
701
- // without this grant the launch + ps-readiness calls hang to timeout.
702
- sub: { allow: [`_INBOX_${id}.>`, `${controlServiceSubject(space, CONTROL_ADMIN, id)}.reply.>`] },
796
+ // Own inbox (presence/registry watch delivery + JS API responses) + the BOUNDED control-reply
797
+ // subtree for the same tier: `requestControl(tier, launch/ps)` subscribes `ctl.<tier>.<id>.reply.<uuid>`,
798
+ // so without this grant the launch + ps-readiness calls hang to timeout.
799
+ sub: { allow: [`_INBOX_${pr.connId}.>`, `${controlServiceSubject(space, tier, pr.owner, pr.actor)}.reply.>`] },
703
800
  };
704
801
  }
705
802
  /** The ephemeral PURGER permission set (closure (ii), residual 2) — minted per-purge inside the daemon's
@@ -707,7 +804,7 @@ function deployerPermissions(space, id) {
707
804
  * (`STREAM.PURGE.CHAT` + `STREAM.PURGE.DM`) off the always-on supervisor: `--dms` purges the DM stream,
708
805
  * exactly the grant the supervisor must not hold. It PURGES but never READS — no DM/chat consumer, no
709
806
  * `MSG.GET` — so a leaked purger can drop history but cannot read a body. Short-lived (one purge call). */
710
- function purgerPermissions(space, id) {
807
+ function purgerPermissions(space, pr) {
711
808
  const CHAT = chatStream(space), DM = dmStream(space);
712
809
  return {
713
810
  pub: {
@@ -719,7 +816,7 @@ function purgerPermissions(space, id) {
719
816
  // NOTE: this profile does NOT cover `clearChannel` (web/`down -f` channel-delete) — that also does a
720
817
  // `$KV.<channelBucket>.<ch>` registry delete this cred lacks; it stays on the broad operator/CLI cred.
721
818
  },
722
- sub: { allow: [`_INBOX_${id}.>`] },
819
+ sub: { allow: [`_INBOX_${pr.connId}.>`] },
723
820
  };
724
821
  }
725
822
  /** The ephemeral PROVISIONER permission set (closure (ii), residual 2) — the onboarding authority,
@@ -744,7 +841,7 @@ function purgerPermissions(space, id) {
744
841
  * DM/DLV (it creates the bind-only mailbox but never reads it), NO STREAM.DELETE/PURGE/UPDATE/MSG.DELETE
745
842
  * (it provisions, it does not tear down or tamper). KV value-writes are scoped to exactly the two
746
843
  * registries provisioning touches: the read-ACL bucket (`commitAcl`) and the channel registry (seed). */
747
- function provisionerPermissions(space, id) {
844
+ function provisionerPermissions(space, pr) {
748
845
  const CHAT = chatStream(space), DM = dmStream(space), TASK = taskStream(space);
749
846
  const INBOX = inboxStream(space), DLV = dlvStream(space);
750
847
  // Every backing stream the provisioner pre-creates — the 5 message streams + the KV buckets (a bucket's
@@ -791,15 +888,16 @@ function provisionerPermissions(space, id) {
791
888
  },
792
889
  // Replies only: every stream/consumer/KV-create PubAck and JS API response lands on the per-id inbox.
793
890
  // NO chat/inst/dlv/ctl subscription — the provisioner never serves control nor reads any feed.
794
- sub: { allow: [`_INBOX_${id}.>`] },
891
+ sub: { allow: [`_INBOX_${pr.connId}.>`] },
795
892
  };
796
893
  }
797
894
  /** The ephemeral, TARGET-PINNED DEPROVISIONER permission set (#159 Part B) — the teardown counterpart
798
895
  * to {@link provisionerPermissions}, minted per departed agent inside the manager's `deprovision` tail
799
- * (`withProvisioner`-style: a fresh scoped cred per teardown is cheap). It deletes exactly the id-keyed
800
- * footprint the provisioner created for ONE agent: that agent's two bind-only durables (`dm_<id>`,
801
- * `dlv_<id>`) and its read-ACL row — pinned BY NAME to the `target` id, so a leaked deprovisioner cred
802
- * can tear down that one (already-dead) agent and NOTHING else.
896
+ * (`withProvisioner`-style: a fresh scoped cred per teardown is cheap). It deletes exactly the
897
+ * dev/static principal footprint the provisioner created for ONE agent: that agent's two bind-only
898
+ * durables (`dm_local-<actor>`, `dlv_local-<actor>`) and its read-ACL row — pinned BY NAME to the target
899
+ * actor under {@link DEV_OWNER}, so a leaked deprovisioner cred can tear down that one already-dead
900
+ * agent and NOTHING else.
803
901
  *
804
902
  * Deliberately NOT granted (least-privilege / correctness): the role-SHARED `svc_<role>` TASK durable
805
903
  * (one consumer for ALL agents of a role — deleting it on one agent's exit would break its siblings; it
@@ -809,29 +907,31 @@ function provisionerPermissions(space, id) {
809
907
  * need no grant here — they are ephemeral (`mem_storage`, 30s inactive threshold) and agent-deleted
810
908
  * after each read, so they self-clean on the agent's disconnect.
811
909
  *
812
- * Blast radius of a leaked cred (minted for target T): it can delete T's `dm_<T>`/`dlv_<T>` durables +
813
- * purge T's ACL row — a denial-of-DELIVERY for T (broken DM/DLV bind + the reader DEFERs on the absent
814
- * ACL) if fired while T is still alive. It CANNOT read T's bodies, impersonate T, reach any peer, or
910
+ * Blast radius of a leaked cred (minted for target T): it can delete T's `dm_local-<T>`/`dlv_local-<T>`
911
+ * durables + purge T's ACL row — a denial-of-DELIVERY for T (broken DM/DLV bind + the reader DEFERs on
912
+ * the absent ACL) if fired while T is still alive. It CANNOT read T's bodies, impersonate T, reach any peer, or
815
913
  * delete a stream — and it is ephemeral (one per-exit teardown, minted then dropped). Contained and
816
914
  * recoverable (re-provision T). */
817
- function deprovisionerPermissions(space, id, target) {
915
+ function deprovisionerPermissions(space, pr, targetActor) {
818
916
  const DM = dmStream(space), DLV = dlvStream(space);
917
+ const t = deprovisionTargetPrincipal(targetActor);
918
+ const target = principalKey(t.owner, t.actor);
819
919
  return {
820
920
  pub: {
821
921
  allow: [
822
922
  "$JS.API.INFO", // jetstreamManager bootstrap
823
- // Delete the target's two bind-only durables BY EXACT NAME (id-keyed) — no `.>`, no cross-agent reach.
824
- `$JS.API.CONSUMER.DELETE.${DM}.${dmDurable(target)}`,
825
- `$JS.API.CONSUMER.DELETE.${DLV}.${dlvDurable(target)}`,
923
+ // Delete the target's two bind-only durables BY EXACT NAME — no `.>`, no cross-agent reach.
924
+ `$JS.API.CONSUMER.DELETE.${DM}.${dmDurable(t.owner, t.actor)}`,
925
+ `$JS.API.CONSUMER.DELETE.${DLV}.${dlvDurable(t.owner, t.actor)}`,
826
926
  // Purge the target's read-ACL row (own-target key only — the reader then treats it as an unknown
827
927
  // owner). `kvm.open` binds the pre-created bucket; the purge rides `$KV.<aclBucket>.<key>`.
828
928
  `$JS.API.STREAM.INFO.KV_${aclBucket(space)}`,
829
- `$KV.${aclBucket(space)}.${aclKey(target)}`,
929
+ `$KV.${aclBucket(space)}.${aclKey(target.key)}`,
830
930
  ],
831
931
  },
832
- // Replies only: the CONSUMER.DELETE PubAcks + KV purge ack land on the per-id inbox. NO chat/DM/ctl
932
+ // Replies only: the CONSUMER.DELETE PubAcks + KV purge ack land on the per-connection inbox. NO chat/DM/ctl
833
933
  // subscription — the deprovisioner serves nothing and reads no feed.
834
- sub: { allow: [`_INBOX_${id}.>`] },
934
+ sub: { allow: [`_INBOX_${pr.connId}.>`] },
835
935
  };
836
936
  }
837
937
  /** The scoped `delivery` daemon permission set (server-side Plane-3 infra; NEVER allow-all, never
@@ -843,7 +943,7 @@ function deprovisionerPermissions(space, id, target) {
843
943
  * subscription — a leaked cred can't natively sniff the mixed pre-auth store. Honest blast radius
844
944
  * (delivery-daemon.md): it can write any owner's `dlv` (the post-auth store agents trust); the future
845
945
  * fan-out/reader cred split bounds that. */
846
- function deliveryPermissions(space, id) {
946
+ function deliveryPermissions(space, pr) {
847
947
  const p = spacePrefix(space);
848
948
  const CHAT = chatStream(space), INBOX = inboxStream(space), DLV = dlvStream(space);
849
949
  const PKV = `KV_${presenceBucket(space)}`, CHKV = `KV_${channelBucket(space)}`;
@@ -884,17 +984,22 @@ function deliveryPermissions(space, id) {
884
984
  // Delivery lease/readiness KV: read the bucket (renew CAS) + write ONLY lease keys.
885
985
  `$JS.API.STREAM.INFO.${DKV}`, `$JS.API.STREAM.MSG.GET.${DKV}`,
886
986
  `$KV.${deliveryBucket(space)}.lease.*`,
887
- // Plane-3 data writes: dinbox (fan-out target) + dlv (post-auth handoff) for ANY owner.
888
- `${p}.dinbox.*`, `${p}.dlv.*`,
987
+ // Plane-3 data writes: dinbox (fan-out target) + dlv (post-auth handoff) for ANY principal — the
988
+ // owner+actor slots widen to `.*.*` (dinbox/dlv are per-agent now).
989
+ `${p}.dinbox.*.*`, `${p}.dlv.*.*`,
889
990
  // ctl.delivery control REPLIES ONLY (requests arrive on the sub below; the daemon only ever
890
- // m.respond()s to a requester's reply subject `ctl.delivery.<id>.reply.<n>`). Scoped to the
891
- // `.reply.>` leaf so the daemon can't publish to the request subjects themselves — tighter than a
892
- // blanket `ctl.delivery.>` (fact-check precision, review panel).
893
- `${p}.ctl.delivery.*.reply.>`,
991
+ // m.respond()s to a requester's reply subject `ctl.delivery.<owner>.<actor>.reply.<n>`). Scoped to
992
+ // the `.reply.>` leaf so the daemon can't publish to the request subjects themselves — tighter than a
993
+ // blanket `ctl.delivery.>` (fact-check precision, review panel). The caller slots widened to `.*.*`.
994
+ `${p}.ctl.delivery.*.*.reply.>`,
995
+ // The privileged delivery-admin rail (D5 slice 5/6): same replies-only shape. Requests reach the
996
+ // daemon on the sub below; only the supervisor cred can PUBLISH them (nats-server is the boundary).
997
+ `${p}.ctl.delivery-admin.*.*.reply.>`,
894
998
  ];
895
999
  const sub = [
896
- `_INBOX_${id}.>`,
897
- `${p}.ctl.delivery.*`, // serve the delivery control service (queue-grouped durable join/leave/list)
1000
+ `_INBOX_${pr.connId}.>`,
1001
+ `${p}.ctl.delivery.*.*`, // serve the delivery control service (queue-grouped; owner+actor caller slots)
1002
+ `${p}.ctl.delivery-admin.*.*`, // serve the privileged admin rail (reloadCreds; eviction executor next)
898
1003
  ];
899
1004
  return { pub: { allow: pub }, sub: { allow: sub } };
900
1005
  }
@@ -905,7 +1010,7 @@ function deliveryPermissions(space, id) {
905
1010
  * isolation keeps the system-account CONNZ read on the SEPARATE conn-A cred). A leaked conn-B cred can
906
1011
  * read durable-membership records and forge the feed — bounded to "dashboard integrity" by the
907
1012
  * display-only invariant; it reads no message bodies and admins nothing. */
908
- function membershipRwPermissions(space, id) {
1013
+ function membershipRwPermissions(space, pr) {
909
1014
  const MKV = `KV_${membersBucket(space)}`; // durable arm — read
910
1015
  const MEMKV = `KV_${membershipBucket(space)}`; // derived feed — read (diff/prune) + write
911
1016
  const kvRead = (bucket) => [
@@ -922,7 +1027,7 @@ function membershipRwPermissions(space, id) {
922
1027
  `$KV.${membershipBucket(space)}.>`, // write derived feed (kv.put + kv.delete)
923
1028
  "$JS.FC.>", // ordered-consumer flow control
924
1029
  ];
925
- return { pub: { allow: pub }, sub: { allow: [`_INBOX_${id}.>`] } };
1030
+ return { pub: { allow: pub }, sub: { allow: [`_INBOX_${pr.connId}.>`] } };
926
1031
  }
927
1032
  /** The scoped SYSTEM-account `membership-observer` permission set (the graph feed's conn A). An EXPLICIT
928
1033
  * block is MANDATORY: a system-account user with NO permissions block defaults to ALLOW-ALL = full
@@ -953,12 +1058,46 @@ function membershipObserverPermissions(accountId) {
953
1058
  * original `up`): the observer can only be minted at the (re-)provision that creates the account — a
954
1059
  * documented migration property, not a silent no-op. The CONNZ/event subjects pin the DATA account id
955
1060
  * (`auth.account.pub`). Mirrors {@link mintCreds} but issues into the system account. */
956
- export async function mintMembershipObserverCreds(auth, identity) {
1061
+ export async function mintMembershipObserverCreds(auth, identity, opts = {}) {
957
1062
  if (!auth.sys.signingSeed)
958
- throw new Error("mintMembershipObserverCreds: no in-memory system-account signing seed the observer can only be minted at the `up` that provisions the account (the $SYS seed is never persisted). Re-provision (down/up) to enable broker-sourced membership.");
1063
+ throw new Error("mintMembershipObserverCreds: no in-memory system-account signing seed - the observer can only be minted at the `up` that provisions the account (the $SYS seed is never persisted). Re-provision (down/up) to enable broker-sourced membership.");
959
1064
  const signer = fromSeed(new TextEncoder().encode(auth.sys.signingSeed));
960
1065
  const perms = membershipObserverPermissions(auth.account.pub);
961
- const userJwt = await encodeUser("membership-observer", fromPublic(identity.id), fromPublic(auth.sys.pub), perms, { signer });
1066
+ // Bounded exp (D5 slice 5): the observer is `rotation-renewed` — it carries the matrix's default
1067
+ // lifetime so a copied cred becomes broker-dead, but there is NO online renewal (the $SYS seed is
1068
+ // gone after `up`); renewal is a coordinated system-account rotation + restart.
1069
+ const validDates = userValidDates("membership-observer", opts);
1070
+ const userJwt = await encodeUser("membership-observer", fromPublic(identity.id), fromPublic(auth.sys.pub), perms, { signer, ...validDates });
1071
+ const creds = fmtCreds(userJwt, fromSeed(new TextEncoder().encode(identity.seed)));
1072
+ return new TextDecoder().decode(creds);
1073
+ }
1074
+ /** The KICK-ONLY connection-evictor permission set (D5 slice 4) — a SYSTEM-account user that can do
1075
+ * exactly ONE thing: `$SYS.REQ.SERVER.*.KICK` (disconnect a live client by cid). It CANNOT read
1076
+ * CONNZ (discovery stays on the separate observer cred — never one broad sys user that both
1077
+ * enumerates and kills), touch any other `$SYS` verb, or reach another account's data. A leaked
1078
+ * evictor cred can DoS live connections on this broker (KICK is not account-scoped — the honest
1079
+ * blast radius), which is why it is a HIGH-POWER standing credential: minted only at `up`,
1080
+ * rate-limited + audited by its one caller (the delivery daemon), and its cid/server-id inputs come
1081
+ * only from the observer's own CONNZ scan, never a user-facing API. Wildcard `*` over server id
1082
+ * because a cluster's server ids aren't known at mint time; the scan pins the exact id per KICK. */
1083
+ function connectionEvictorPermissions() {
1084
+ return {
1085
+ pub: { allow: ["$SYS.REQ.SERVER.*.KICK"] },
1086
+ // Request/reply KICK replies land on the client's default inbox; no other subscription — it
1087
+ // serves nothing and reads no feed.
1088
+ sub: { allow: ["_INBOX.>"] },
1089
+ };
1090
+ }
1091
+ /** Mint the scoped `connection-evictor` creds — the kick-only SYSTEM-account user D5 slice 4's live
1092
+ * eviction holds. Same mint-only-at-provision property as the observer (the $SYS seed is in-memory
1093
+ * only), same fail-loud when it's absent. Paired with the observer at `up`. */
1094
+ export async function mintConnectionEvictorCreds(auth, identity, opts = {}) {
1095
+ if (!auth.sys.signingSeed)
1096
+ throw new Error("mintConnectionEvictorCreds: no in-memory system-account signing seed - the evictor can only be minted at the `up` that provisions the account (the $SYS seed is never persisted). Re-provision (down/up) to enable live eviction.");
1097
+ const signer = fromSeed(new TextEncoder().encode(auth.sys.signingSeed));
1098
+ // Bounded exp (D5 slice 5): `rotation-renewed`, same posture as the observer above.
1099
+ const validDates = userValidDates("connection-evictor", opts);
1100
+ const userJwt = await encodeUser("connection-evictor", fromPublic(identity.id), fromPublic(auth.sys.pub), connectionEvictorPermissions(), { signer, ...validDates });
962
1101
  const creds = fmtCreds(userJwt, fromSeed(new TextEncoder().encode(identity.seed)));
963
1102
  return new TextDecoder().decode(creds);
964
1103
  }
@@ -974,7 +1113,7 @@ export function serverConfig(auth, opts) {
974
1113
  // agent JWT — but right-sized, not generous: the CONNECT line is parsed BEFORE auth, so the cap
975
1114
  // is a per-connection pre-auth allocation under connection flooding. 64 KB clears a many-channel
976
1115
  // agent JWT (~4–8 KB) with wide margin while keeping the pre-auth surface ~16× tighter than 1 MB.
977
- return `# Generated by \`cotal up\` do not edit by hand.
1116
+ return `# Generated by \`cotal up\` - do not edit by hand.
978
1117
  host: ${host}
979
1118
  port: ${port}
980
1119
  max_control_line: 65536
@@ -984,7 +1123,7 @@ system_account: ${auth.sys.pub}
984
1123
  resolver: MEMORY
985
1124
  resolver_preload: {
986
1125
  ${auth.account.pub}: ${auth.account.jwt}
987
- ${auth.sys.pub}: ${auth.sys.jwt}
1126
+ ${auth.sys.pub}: ${auth.sys.jwt}${(opts.extraAccounts ?? []).map((a) => `\n ${a.pub}: ${a.jwt}`).join("")}
988
1127
  }
989
1128
  `;
990
1129
  }