@cotal-ai/core 0.16.0 → 0.18.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 (145) hide show
  1. package/dist/acls.d.ts +21 -0
  2. package/dist/acls.d.ts.map +1 -1
  3. package/dist/acls.js +44 -1
  4. package/dist/acls.js.map +1 -1
  5. package/dist/artifact.d.ts +45 -0
  6. package/dist/artifact.d.ts.map +1 -0
  7. package/dist/artifact.js +82 -0
  8. package/dist/artifact.js.map +1 -0
  9. package/dist/backup-config.d.ts +9 -2
  10. package/dist/backup-config.d.ts.map +1 -1
  11. package/dist/backup-config.js +6 -2
  12. package/dist/backup-config.js.map +1 -1
  13. package/dist/broker-tls.d.ts +70 -0
  14. package/dist/broker-tls.d.ts.map +1 -0
  15. package/dist/broker-tls.js +195 -0
  16. package/dist/broker-tls.js.map +1 -0
  17. package/dist/canonical.d.ts +14 -1
  18. package/dist/canonical.d.ts.map +1 -1
  19. package/dist/canonical.js +21 -1
  20. package/dist/canonical.js.map +1 -1
  21. package/dist/channels.js +4 -4
  22. package/dist/channels.js.map +1 -1
  23. package/dist/endpoint-action.d.ts +108 -3
  24. package/dist/endpoint-action.d.ts.map +1 -1
  25. package/dist/endpoint-action.js +209 -20
  26. package/dist/endpoint-action.js.map +1 -1
  27. package/dist/endpoint-binding.d.ts +138 -8
  28. package/dist/endpoint-binding.d.ts.map +1 -1
  29. package/dist/endpoint-binding.js +233 -26
  30. package/dist/endpoint-binding.js.map +1 -1
  31. package/dist/endpoint-contract-store.d.ts +30 -1
  32. package/dist/endpoint-contract-store.d.ts.map +1 -1
  33. package/dist/endpoint-contract-store.js +100 -29
  34. package/dist/endpoint-contract-store.js.map +1 -1
  35. package/dist/endpoint-envelope.d.ts +15 -37
  36. package/dist/endpoint-envelope.d.ts.map +1 -1
  37. package/dist/endpoint-envelope.js +93 -44
  38. package/dist/endpoint-envelope.js.map +1 -1
  39. package/dist/endpoint-error.d.ts +33 -0
  40. package/dist/endpoint-error.d.ts.map +1 -0
  41. package/dist/endpoint-error.js +34 -0
  42. package/dist/endpoint-error.js.map +1 -0
  43. package/dist/endpoint-grants.d.ts +63 -7
  44. package/dist/endpoint-grants.d.ts.map +1 -1
  45. package/dist/endpoint-grants.js +118 -9
  46. package/dist/endpoint-grants.js.map +1 -1
  47. package/dist/endpoint-invoke.d.ts +138 -0
  48. package/dist/endpoint-invoke.d.ts.map +1 -0
  49. package/dist/endpoint-invoke.js +393 -0
  50. package/dist/endpoint-invoke.js.map +1 -0
  51. package/dist/endpoint-records.d.ts.map +1 -1
  52. package/dist/endpoint-records.js +17 -0
  53. package/dist/endpoint-records.js.map +1 -1
  54. package/dist/endpoint-serve-kv.d.ts +97 -0
  55. package/dist/endpoint-serve-kv.d.ts.map +1 -0
  56. package/dist/endpoint-serve-kv.js +336 -0
  57. package/dist/endpoint-serve-kv.js.map +1 -0
  58. package/dist/endpoint-serve.d.ts.map +1 -1
  59. package/dist/endpoint-serve.js +21 -1
  60. package/dist/endpoint-serve.js.map +1 -1
  61. package/dist/endpoint-service.d.ts +1 -1
  62. package/dist/endpoint-service.d.ts.map +1 -1
  63. package/dist/endpoint-service.js +34 -10
  64. package/dist/endpoint-service.js.map +1 -1
  65. package/dist/endpoint-session-rail.d.ts +127 -0
  66. package/dist/endpoint-session-rail.d.ts.map +1 -0
  67. package/dist/endpoint-session-rail.js +325 -0
  68. package/dist/endpoint-session-rail.js.map +1 -0
  69. package/dist/endpoint-session.d.ts +1 -115
  70. package/dist/endpoint-session.d.ts.map +1 -1
  71. package/dist/endpoint-session.js +4 -316
  72. package/dist/endpoint-session.js.map +1 -1
  73. package/dist/endpoint-subjects.d.ts +20 -1
  74. package/dist/endpoint-subjects.d.ts.map +1 -1
  75. package/dist/endpoint-subjects.js +20 -1
  76. package/dist/endpoint-subjects.js.map +1 -1
  77. package/dist/endpoint-verbs.d.ts +1 -2
  78. package/dist/endpoint-verbs.d.ts.map +1 -1
  79. package/dist/endpoint-verbs.js +6 -6
  80. package/dist/endpoint-verbs.js.map +1 -1
  81. package/dist/endpoint.d.ts +157 -11
  82. package/dist/endpoint.d.ts.map +1 -1
  83. package/dist/endpoint.js +471 -47
  84. package/dist/endpoint.js.map +1 -1
  85. package/dist/evict.d.ts +47 -0
  86. package/dist/evict.d.ts.map +1 -1
  87. package/dist/evict.js +191 -59
  88. package/dist/evict.js.map +1 -1
  89. package/dist/identity.d.ts +34 -0
  90. package/dist/identity.d.ts.map +1 -1
  91. package/dist/identity.js +46 -0
  92. package/dist/identity.js.map +1 -1
  93. package/dist/index.d.ts +8 -0
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/index.js +8 -0
  96. package/dist/index.js.map +1 -1
  97. package/dist/kv-scan.d.ts +8 -0
  98. package/dist/kv-scan.d.ts.map +1 -1
  99. package/dist/kv-scan.js +8 -0
  100. package/dist/kv-scan.js.map +1 -1
  101. package/dist/lease.d.ts +12 -5
  102. package/dist/lease.d.ts.map +1 -1
  103. package/dist/lease.js.map +1 -1
  104. package/dist/lifecycle-saga.d.ts +174 -0
  105. package/dist/lifecycle-saga.d.ts.map +1 -0
  106. package/dist/lifecycle-saga.js +418 -0
  107. package/dist/lifecycle-saga.js.map +1 -0
  108. package/dist/lifecycle-state.d.ts +202 -0
  109. package/dist/lifecycle-state.d.ts.map +1 -0
  110. package/dist/lifecycle-state.js +347 -0
  111. package/dist/lifecycle-state.js.map +1 -0
  112. package/dist/parts.d.ts +13 -0
  113. package/dist/parts.d.ts.map +1 -0
  114. package/dist/parts.js +17 -0
  115. package/dist/parts.js.map +1 -0
  116. package/dist/provision.d.ts +124 -15
  117. package/dist/provision.d.ts.map +1 -1
  118. package/dist/provision.js +578 -121
  119. package/dist/provision.js.map +1 -1
  120. package/dist/schema-profile.d.ts +3 -2
  121. package/dist/schema-profile.d.ts.map +1 -1
  122. package/dist/schema-profile.js +242 -18
  123. package/dist/schema-profile.js.map +1 -1
  124. package/dist/session-browser.d.ts +15 -0
  125. package/dist/session-browser.d.ts.map +1 -0
  126. package/dist/session-browser.js +14 -0
  127. package/dist/session-browser.js.map +1 -0
  128. package/dist/session-terminal-frames.d.ts +36 -0
  129. package/dist/session-terminal-frames.d.ts.map +1 -0
  130. package/dist/session-terminal-frames.js +149 -0
  131. package/dist/session-terminal-frames.js.map +1 -0
  132. package/dist/spaces.d.ts.map +1 -1
  133. package/dist/spaces.js +6 -1
  134. package/dist/spaces.js.map +1 -1
  135. package/dist/streams.d.ts +168 -1
  136. package/dist/streams.d.ts.map +1 -1
  137. package/dist/streams.js +299 -21
  138. package/dist/streams.js.map +1 -1
  139. package/dist/subjects.d.ts +34 -36
  140. package/dist/subjects.d.ts.map +1 -1
  141. package/dist/subjects.js +46 -35
  142. package/dist/subjects.js.map +1 -1
  143. package/dist/types.d.ts +27 -1
  144. package/dist/types.d.ts.map +1 -1
  145. package/package.json +6 -1
package/dist/provision.js CHANGED
@@ -14,13 +14,17 @@
14
14
  * D5 adds the first credential-death primitive: profile-classified user-JWT lifetimes. Full revocation,
15
15
  * live eviction, standing-host renewal, and issuance audit still land in later D5 slices.
16
16
  */
17
+ import { ttlBuckets } from "./streams.js";
17
18
  import { join } from "node:path";
18
19
  import { decode, encodeOperator, encodeAccount, encodeUser, fmtCreds, } from "@nats-io/jwt";
19
20
  import { createOperator, createAccount, fromPublic, fromSeed } from "@nats-io/nkeys";
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, CONTROL_AUTH_ADMIN, chatStream, dmStream, taskStream, dlvStream, inboxStream, chatHistDurable, dmDurable, taskDurable, dlvDurable, presenceBucket, channelBucket, membersBucket, aclBucket, aclKey, assertLifecycleToken, membershipBucket, deliveryBucket, managerBucket, MANAGER_LEASE_KEY, connzRequestSubject, accountConnectSubject, accountDisconnectSubject, MEMBERSHIP_INBOX_PREFIX, FANOUT_DURABLE, INBOX_READER_DURABLE, } from "./subjects.js";
21
- import { epCallerGrantRows, epServeGrantRows, epBaselineGrantRows, spawnCallerCapabilities } from "./endpoint-grants.js";
21
+ import { token, spacePrefix, chatSubject, assertValidChannel, channelInAllow, principalKey, parsePrincipalKey, deprovisionTargetPrincipal, principalTags, assertInboxConnId, DEV_OWNER, unicastSubject, anycastSubject, controlServiceSubject, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, artifactBucket, objectStoreStream, chatStream, dmStream, taskStream, dlvStream, inboxStream, chatHistDurable, dmDurable, taskDurable, dlvDurable, presenceBucket, channelBucket, membersBucket, aclBucket, aclKey, assertLifecycleToken, membershipBucket, deliveryBucket, managerBucket, MANAGER_LEASE_KEY, connzRequestSubject, accountConnectSubject, accountDisconnectSubject, MEMBERSHIP_INBOX_PREFIX, FANOUT_DURABLE, INBOX_READER_DURABLE, } from "./subjects.js";
22
+ import { epCallerGrantRows, epServeGrantRows, epBaselineGrantRows, spawnCallerCapabilities, epRequestGrantRows, operatorInstrumentCapabilities, epDescribeAllGrantRow, BASELINE_LIFECYCLE_ENDPOINT, } from "./endpoint-grants.js";
22
23
  import { assertServeGrantMintable, finalizeServeIssuance } from "./endpoint-service.js";
23
- import { effectsBindGrants, poolOwnerBindGrants } from "./endpoint-binding.js";
24
+ import { effectsBindGrants, poolOwnerBindGrants, goalWriterGrants, sessionLedgerGrants, epAuthBucket, sessionsBucket, epcStreamName, epjStreamName, epfStreamName, epeStreamName, eptReqStreamName, eprStreamName, eptStreamName, epwStreamName } from "./endpoint-binding.js";
25
+ import { epsSubject, epCallerReplyFilter, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE } from "./endpoint-subjects.js";
26
+ import { recordsBucket, recordSpecKey, recordStatusKey, recordAtomicKey, RECORD_KINDS, GOVERN_HEAD } from "./endpoint-records.js";
27
+ import { lifecycleHeadKey, uidReservationKey, issuanceGateKey, staticSlotKey, STATIC_SLOT_PREFIX, epgateKey, epcredFamilyPrefix } from "./lifecycle-state.js";
24
28
  import { rawDigest } from "./canonical.js";
25
29
  import { credsClaims } from "./identity.js";
26
30
  import { backupProfilePermissions, restoreProfilePermissions, } from "./backup.js";
@@ -49,6 +53,8 @@ export const CREDENTIAL_LIFETIMES = {
49
53
  provisioner: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "setup/spawn provisioning window only" },
50
54
  deprovisioner: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "target-pinned teardown window only" },
51
55
  "retirement-requester": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "one despawn's retirement request window; request+reply only" },
56
+ "lifecycle-executor": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "one static lifecycle operation's 13.1 state-write window (activation / terminal / renewal ledger append)" },
57
+ "endpoint-serve-executor": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "one endpoint registration/serve-mint window (13.1 epgate CAS + epcred stage/revoke for one (endpoint, instanceId))" },
52
58
  operator: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "send/dm/join/probe-style operator command" },
53
59
  purger: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "history purge command" },
54
60
  backup: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "offline snapshot phase; exact stream and delivery subject, memory-only" },
@@ -61,6 +67,11 @@ export const CREDENTIAL_LIFETIMES = {
61
67
  "control-caller-admin": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "stop/attach admin control call" },
62
68
  deployer: { class: "one-shot", note: "manifest deploy spans planning/launch/ledger; needs near-expiry guard or remint before default exp" },
63
69
  "endpoint-serve": { class: "standing-renewable", defaultTtlSeconds: STANDING_RENEWABLE_TTL_SEC, renewalOwner: "manager", note: "per-instance endpoint serve credential (SPEC 13.9); the managing authority re-mints on renewal and on takeover (new epoch), and the 13.1 barrier revokes the superseded one" },
70
+ "goal-writer": { class: "standing-renewable", defaultTtlSeconds: STANDING_RENEWABLE_TTL_SEC, renewalOwner: "manager", note: "self-mediated goal-writer for spawn-as-action (P2 item 2); the manager re-mints for the SAME nkey on renewal, disjoint from the serve credential (Q2)" },
71
+ "session-caller": { class: "one-shot", defaultTtlSeconds: 24 * 60 * 60, note: "per-session console/CLI caller cred (P2 item 6): rails-only for ONE §13.6 session; TTL-BOUND to the session (the face mints with expiresAt = the session exp; the 24h default is the SESSION_GRANT_MAX_TTL ceiling, never a standing lifetime); NEVER renewed - a new session mints a new cred" },
72
+ "session-serving": { class: "one-shot", defaultTtlSeconds: 24 * 60 * 60, note: "per-session SERVING cred (P2 item 6): rails-only for ONE §13.6 session, the mirror of session-caller with the directions swapped; minted at redemption and TTL-BOUND to the session (the 24h default is the SESSION_GRANT_MAX_TTL ceiling, never a standing lifetime); NEVER renewed - a new session mints a new cred, and the session's terminal revokes this one by name" },
73
+ "session-ledger": { class: "standing-renewable", defaultTtlSeconds: STANDING_RENEWABLE_TTL_SEC, renewalOwner: "manager", note: "manager's session LEDGER (P2 item 6): the dedicated sessions-bucket `session.<id>` rows and NOTHING else - no session rail of any shape. Standing because SPEC 13.6 makes it the durable revocation authority that must survive the serving endpoint; the manager re-mints for the SAME nkey on the half-TTL loop (the goal-writer precedent)" },
74
+ "endpoint-evictor": { class: "one-shot", defaultTtlSeconds: 60, note: "one re-registration's verify-evict window (P2 item 3): a scoped delivery-admin caller that kicks+verifies the SUPERSEDED serve family before the epoch advances; 60s bounds a copied cred to a minute" },
64
75
  "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" },
65
76
  "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" },
66
77
  };
@@ -322,8 +333,13 @@ export async function provisionAgentDurables(provisioner, pr, opts = {}) {
322
333
  // live-only launcher, e.g. `cotal spawn --live-only`) opts out of the ACL row → the daemon never
323
334
  // authorizes a durable backstop for it, so it stays live-only.
324
335
  // ACL is keyed by the lifecycle-scoped dot-form <owner>.<actor>.<uid> (per-incarnation read authority).
325
- if (opts.durableMembership !== false)
326
- await provisioner.commitAcl(principalKey(pr.owner, pr.actor).key, uid, allowSubscribe);
336
+ // reissueAcl — rides THIS mint: allowSubscribe was just baked into the user JWT above. Separate
337
+ // from commitAcl so an ordinary registry writer cannot raise the ceiling by passing a flag
338
+ // (SPEC §9.6). Not crypto-bound to the JWT bytes — process discipline that this call stays next
339
+ // to the mint (ACL-authority panel residual).
340
+ if (opts.durableMembership !== false) {
341
+ await provisioner.reissueAcl(principalKey(pr.owner, pr.actor).key, uid, allowSubscribe);
342
+ }
327
343
  if (opts.role)
328
344
  await provisioner.provisionTaskQueue(opts.role);
329
345
  return allowSubscribe;
@@ -425,14 +441,78 @@ export function permissionsFor(profile, space, pr, opts) {
425
441
  return deprovisionerPermissions(space, pr, opts.deprovisionTarget);
426
442
  }
427
443
  if (profile === "retirement-requester") {
428
- // Ephemeral request+reply on the auth-admin rail (#29 piece 3): publish EXACTLY the
429
- // requester's own control subject + subscribe its own reply subtree and inbox. No store
430
- // reads, no barrier/scanner/plane authority - the requester only asks; the auth plane
431
- // holds every executing right and re-checks the lease at serve time.
432
- if (!opts.retirementRequester)
433
- throw new Error("permissionsFor: retirement-requester requires opts.retirementRequester ({owner, actor} of the requesting manager)");
434
- const req = controlServiceSubject(space, CONTROL_AUTH_ADMIN, opts.retirementRequester.owner, opts.retirementRequester.actor);
435
- return { pub: { allow: [req] }, sub: { allow: [`${req}.reply.>`, `_INBOX_${pr.connId}.>`] } };
444
+ // Ephemeral request+reply on the AUTH ENDPOINT rail (#29 piece 3; moved off `ctl` by #350):
445
+ // publish EXACTLY this caller triple's own request subject for the ONE target it names +
446
+ // subscribe its own reply-plane filter and inbox. No store reads, no barrier/scanner/plane
447
+ // authority - the requester only asks; the auth plane holds every executing right and
448
+ // re-checks the serve registration at serve time.
449
+ // Validate the WHOLE shape, not just its presence. Each of these is a subject token: a missing
450
+ // one would otherwise surface as a raw "cannot read properties of undefined" from deep inside
451
+ // the subject builder, which tells the operator nothing about WHICH field it owes. The `target`
452
+ // arrived with #350 (the handle triple moved into the subject), so a caller written against the
453
+ // pre-#350 `{owner, actor}` shape lands here — and must be told exactly that.
454
+ const rr = opts.retirementRequester;
455
+ if (!rr)
456
+ throw new Error("permissionsFor: retirement-requester requires opts.retirementRequester ({owner, actor, uid, target} of the requesting manager)");
457
+ for (const [k, v] of [["owner", rr.owner], ["actor", rr.actor], ["uid", rr.uid]])
458
+ if (typeof v !== "string" || v.length === 0)
459
+ throw new Error(`permissionsFor: retirement-requester requires opts.retirementRequester.${k} (the caller triple is <owner>.<actor>.<uid>; since #350 the rail carries the CALLER's uid, not a two-token alias)`);
460
+ if (!rr.target || typeof rr.target.owner !== "string" || typeof rr.target.actor !== "string" || typeof rr.target.lifecycleUid !== "string")
461
+ throw new Error("permissionsFor: retirement-requester requires opts.retirementRequester.target ({owner, actor, lifecycleUid} of the ONE incarnation this credential may retire) - since #350 the handle target rides the SUBJECT and is grant-pinned, so it can no longer be supplied in the request body");
462
+ const { owner, actor, uid, target } = rr;
463
+ const caller = { owner, actor, uid };
464
+ // DEVIATION FROM `handle`'s NORMATIVE PROVENANCE, stated where the row is minted (SPEC
465
+ // 1314-1319, 1838-1863). `handle` is normatively REDEMPTION-MINTED: its triple is pinned at
466
+ // redemption from an ISSUER-SIGNED capability artifact, and the mode carries attenuation
467
+ // (`effective = presenter-cred INTERSECT handle.grants INTERSECT issuer-authority`), conferral
468
+ // through the trusted auth service, and ledgered `sourceChain` lineage.
469
+ // THIS PATH HAS NONE OF THAT: there is NO issuer-signed artifact, NO redemption step and NO
470
+ // sourceChain. The row is built directly from the manager's own coordinates under root
471
+ // authority. It is used because `handle` is the ONLY mode with arity 3 - every other mode
472
+ // resolves against the CURRENT mapping, which is the wrong semantics for retiring a NAMED
473
+ // incarnation - and because the reader-facing invariant ("the validator re-checks only
474
+ // currency") IS honoured: the auth handler fresh-checks the triple against the lifecycle
475
+ // mapping and refuses a stale incarnation.
476
+ // What is genuinely absent is delegation lineage and artifact revocation. There is no
477
+ // independent issuer/holder boundary on this one-shot path whose revocation would change this
478
+ // requester's authority, which is why the deviation is accepted rather than papered over with
479
+ // a manufactured artifact. NAMED RESIDUAL: Cotal #399 tracks making this genuinely
480
+ // redemption-shaped if real artifact semantics are ever intended.
481
+ // The `handle` target is grant-pinned, so this credential can ask to retire the ONE
482
+ // incarnation it was minted for and nothing else - the same confinement the pre-#350 grant got
483
+ // from naming an exact `ctl` subject, now covering the TARGET as well as the caller. The nonce
484
+ // is the only wildcard token (§13.9): a bounded per-request suffix, not an addressing widening.
485
+ const rows = epRequestGrantRows(space, {
486
+ endpoint: AUTH_ENDPOINT,
487
+ command: EP_CMD_RETIRE_LIFECYCLE,
488
+ target: { mode: "handle", tOwner: target.owner, tActor: target.actor, tUid: target.lifecycleUid },
489
+ }, caller);
490
+ return { pub: { allow: rows }, sub: { allow: [epCallerReplyFilter(space, caller), `_INBOX_${pr.connId}.>`] } };
491
+ }
492
+ if (profile === "endpoint-evictor") {
493
+ // P2 item 3 (slice 3a): a SCOPED delivery-admin caller for ONE re-registration's verify-evict.
494
+ // Publish EXACTLY this credential's OWN delivery-admin control subject + $JS.API.INFO; subscribe
495
+ // its own reply subtree + inbox. NO lease, presence, store read, consumer, KV, or executing
496
+ // right — the daemon does the $SYS scan/KICK (subject-gated authority, like the supervisor evictor
497
+ // it narrows) and the manager passes only the predecessor's principal. Narrower than the
498
+ // `supervisor` profile the auth barrier-evict reuses (its #30 residual, done here for the manager).
499
+ const req = controlServiceSubject(space, CONTROL_DELIVERY_ADMIN, pr.owner, pr.actor);
500
+ return { pub: { allow: [req, "$JS.API.INFO"] }, sub: { allow: [`${req}.reply.>`, `_INBOX_${pr.connId}.>`] } };
501
+ }
502
+ if (profile === "lifecycle-executor") {
503
+ if (!opts.lifecycleExecutor)
504
+ throw new Error("permissionsFor: lifecycle-executor requires opts.lifecycleExecutor ({owner, actor, lifecycleUid, alias} of the ONE incarnation it may move)");
505
+ return lifecycleExecutorPermissions(space, pr, opts.lifecycleExecutor);
506
+ }
507
+ if (profile === "endpoint-serve-executor") {
508
+ if (!opts.endpointServeExecutor)
509
+ throw new Error("permissionsFor: endpoint-serve-executor requires opts.endpointServeExecutor ({endpoint, instanceId} of the ONE endpoint instance it may register/serve-mint)");
510
+ return endpointServeExecutorPermissions(space, pr, opts.endpointServeExecutor);
511
+ }
512
+ if (profile === "goal-writer") {
513
+ if (!opts.goalWriter)
514
+ throw new Error("permissionsFor: goal-writer requires opts.goalWriter ({endpoint} whose action goals this connection binds + commits)");
515
+ return goalWriterPermissions(space, pr, opts.goalWriter);
436
516
  }
437
517
  if (profile === "purger")
438
518
  return purgerPermissions(space, pr); // ephemeral history-purge (closure (ii))
@@ -457,11 +537,17 @@ export function permissionsFor(profile, space, pr, opts) {
457
537
  if (profile === "teardown")
458
538
  return teardownPermissions(space, pr); // sole STREAM.DELETE holder (PR 1.5)
459
539
  if (profile === "control-caller-privileged")
460
- return controlCallerPermissions(space, pr, CONTROL_PRIVILEGED); // ps/start (PR 1.5)
540
+ return controlCallerPermissions(space, pr, "privileged", opts); // ps/start reads (PR 1.5)
461
541
  if (profile === "control-caller-admin")
462
- return controlCallerPermissions(space, pr, CONTROL_ADMIN); // stop/attach (PR 1.5)
542
+ return controlCallerPermissions(space, pr, "admin", opts); // any-mode stop/attach (PR 1.5)
463
543
  if (profile === "deployer")
464
- return deployerPermissions(space, pr, opts.controlTier ?? CONTROL_ADMIN); // spawn -f deploy authority (PR 1.5; user-mode view rides privileged)
544
+ return deployerPermissions(space, pr, opts.controlTier ?? "admin", opts); // spawn -f deploy authority (PR 1.5; user-mode view rides privileged)
545
+ if (profile === "session-caller")
546
+ return sessionCallerPermissions(space, pr, opts.sessionCaller); // one §13.6 session's caller rails (P2 item 6)
547
+ if (profile === "session-serving")
548
+ return sessionServingPermissions(space, pr, opts.sessionServing); // one §13.6 session's SERVING rails (P2 item 6)
549
+ if (profile === "session-ledger")
550
+ return sessionLedgerPermissions(space, pr); // the dedicated session ledger, no rails (P2 item 6)
465
551
  if (profile === "endpoint-serve")
466
552
  // Serve rows are emitted ONLY by mintCreds behind the §13.1 issuance fence — never via this
467
553
  // exported builder, so a direct signer/callout can't obtain unfenced serve rows (SPEC 13.1/13.9).
@@ -542,7 +628,6 @@ export function permissionsFor(profile, space, pr, opts) {
542
628
  // channel must equal its wire token, or the minted grant would alias the logical ACL.
543
629
  for (const ch of [...allowSubscribe, ...allowPublish])
544
630
  assertValidChannel(ch);
545
- const manager = opts.manager ?? CONTROL_PRIVILEGED;
546
631
  if (!pr.lifecycleUid)
547
632
  throw new Error("permissionsFor(agent): a lifecycleUid is required - the agent's dm/dlv/chathist grants are lifecycle-keyed exact names (SPEC 13.1)");
548
633
  const uid = assertLifecycleToken(pr.lifecycleUid);
@@ -555,7 +640,8 @@ export function permissionsFor(profile, space, pr, opts) {
555
640
  ...allowPublish.map((ch) => chatSubject(space, pr.owner, pr.actor, ch)),
556
641
  unicastSubject(space, "*", "*", pr.owner, pr.actor), // inst.*.*.<o>.<a> — DM any instance, as me
557
642
  anycastSubject(space, "*", pr.owner, pr.actor), // svc.*.<o>.<a> — anycast any role, as me
558
- controlServiceSubject(space, CONTROL_SELF_SERVICE, pr.owner, pr.actor), // ctl.self.<o>.<a>self stop/despawn
643
+ // Self stop/despawn rides the v0.4 ep baseline (`stop` self-mode)the manager `ctl` rail is
644
+ // deleted (1d). The delivery-daemon rail below is a separate service (Plane-3), kept.
559
645
  // ctl.delivery.<o>.<a> — request a durable backstop join/leave/list from the SERVER-SIDE delivery
560
646
  // daemon (NOT the manager). The reply rides this same subtree (`ctl.delivery.<o>.<a>.reply.<n>`, in
561
647
  // sub.allow below) so the daemon can answer without broad inbox-publish — see CONTROL_DELIVERY.
@@ -620,20 +706,9 @@ export function permissionsFor(profile, space, pr, opts) {
620
706
  // allowed — the privileged provisioner pre-creates svc_<role> filtered to svc.<role>.*.
621
707
  pubAllow.push(`$JS.API.CONSUMER.INFO.${TASK}.${svcD}`, `$JS.API.CONSUMER.MSG.NEXT.${TASK}.${svcD}`, `$JS.ACK.${TASK}.${svcD}.>`);
622
708
  }
623
- if (opts.capabilities?.includes("spawn")) {
624
- // Spawn capability grant the PRIVILEGED control subject (start / purge / definePersona /
625
- // named stop-despawn). Default-deny otherwise: the subject is simply absent from this
626
- // allow-list, so nats-server rejects the publish — no handler check, no deny-entry (a
627
- // blanket `ctl.<mgr>.>` deny would override this grant too, since NATS deny beats allow).
628
- // The self-service subject above is granted to all regardless of capability.
629
- pubAllow.push(controlServiceSubject(space, manager, pr.owner, pr.actor));
630
- }
631
- if (opts.capabilities?.includes("admin")) {
632
- // Admin capability → the ADMIN control tier (cross-agent stop/attach, manifest launch). In user
633
- // mode this arrives via the ledger row's scope (`cotal actor grant … --scope admin`) — the
634
- // broker-enforced half of the tier split; the manager's per-op checks stay on top of it.
635
- pubAllow.push(controlServiceSubject(space, CONTROL_ADMIN, pr.owner, pr.actor));
636
- }
709
+ // Spawn / admin capability control reach rides the v0.4 ep rows below (the manager `ctl` rail is
710
+ // deleted, 1d) the spawn set (owner-mode manager lifecycle) and the admin instrument set
711
+ // (any-mode + manager.admin family) are added to the ep caller rows, not a ctl subject.
637
712
  // v0.4 endpoint rails (SPEC §13.9 caller rows). EVERY agent gets the Appendix-B BASELINE set
638
713
  // (wildcard describe + delivery join/leave/list + self-mode lifecycle + the reply rail), keyed
639
714
  // on the SAME lifecycle uid as the agent's durables; the spawn capability adds the owner-mode
@@ -651,6 +726,13 @@ export function permissionsFor(profile, space, pr, opts) {
651
726
  const epSub = [...baseline.sub];
652
727
  if (opts.capabilities?.includes("spawn"))
653
728
  pubAllow.push(...epCallerGrantRows(space, spawnCallerCapabilities(pr.owner), epCaller).pub);
729
+ if (opts.capabilities?.includes("admin"))
730
+ // The admin capability's ep mirror (the 1c grant-migration table): the v0.3 `ctl.<admin>`
731
+ // subject above grants the FULL admin-tier op reach, so its holder gets the admin instrument
732
+ // set on the ep rails — any-mode despawn/attach + the `manager.admin` family + the reads. In
733
+ // user mode this is the ledger `admin` scope arriving via the callout, the broker-enforced
734
+ // half of the tier; the manager's ledger-derived per-op admin flag stays on top of it.
735
+ pubAllow.push(...epCallerGrantRows(space, operatorInstrumentCapabilities("admin"), epCaller).pub);
654
736
  if (opts.endpointCapabilities?.length) {
655
737
  if (opts.lifecycleUid !== undefined && assertLifecycleToken(opts.lifecycleUid) !== uid)
656
738
  throw new Error(`permissionsFor: opts.lifecycleUid "${opts.lifecycleUid}" disagrees with the principal's lifecycleUid "${uid}" - one credential names ONE incarnation on the caller rail (SPEC 13.1/13.2)`);
@@ -687,17 +769,9 @@ export function permissionsFor(profile, space, pr, opts) {
687
769
  // Replies to this agent's durable join/leave/list requests ride `ctl.delivery.<o>.<a>.>` (NOT the
688
770
  // per-id _INBOX), so the scoped delivery daemon can answer without broad inbox-publish.
689
771
  const deliveryReplies = `${controlServiceSubject(space, CONTROL_DELIVERY, pr.owner, pr.actor)}.>`;
690
- // Bounded control replies (closure (i)): the manager's lifecycle tiers now reply on
691
- // `ctl.<tier>.<id>.reply.>` (not the per-id `_INBOX`), so each agent must subscribe the reply subtree
692
- // for the tiers it may call. Every agent can self-stop always grant the self tier; the privileged /
693
- // admin tiers' replies are granted only with the matching capability (which also grants the request
694
- // publish above).
695
- const controlReplies = [`${controlServiceSubject(space, CONTROL_SELF_SERVICE, pr.owner, pr.actor)}.reply.>`];
696
- if (opts.capabilities?.includes("spawn"))
697
- controlReplies.push(`${controlServiceSubject(space, CONTROL_PRIVILEGED, pr.owner, pr.actor)}.reply.>`);
698
- if (opts.capabilities?.includes("admin"))
699
- controlReplies.push(`${controlServiceSubject(space, CONTROL_ADMIN, pr.owner, pr.actor)}.reply.>`);
700
- return { pub: { allow: pubAllow, deny: pubDeny }, sub: { allow: [inbox, deliveryReplies, ...controlReplies, ...subChat, ...epSub] } };
772
+ // Manager control replies ride the v0.4 ep reply rail (in `epSub`, keyed on the caller triple)
773
+ // the `ctl.<tier>.<id>.reply.>` subtrees are gone with the ctl rail (1d).
774
+ return { pub: { allow: pubAllow, deny: pubDeny }, sub: { allow: [inbox, deliveryReplies, ...subChat, ...epSub] } };
701
775
  }
702
776
  /** The long-lived SUPERVISOR permission set (closure (ii), residual 2) — the always-on manager daemon
703
777
  * (`manager.ts` `this.ep`), carved down from the former allow-all `manager`. THIS is the cred whose
@@ -710,25 +784,27 @@ export function permissionsFor(profile, space, pr, opts) {
710
784
  * `setActivity`, a presence write), NO DM/DLV read of any kind (no consumer-create, no native sub), NO
711
785
  * stream CREATE/DELETE/PURGE/UPDATE, NO channel-registry access (the daemon sets `watchChannels:false`).
712
786
  * `$JS` is an ENUMERATED allow-list — exactly the presence-watch + lease-KV verbs — never `$JS.>`. A
713
- * leaked supervisor cred can hold/serve control and read the public roster; it cannot read a DM, forge an
787
+ * leaked supervisor cred can hold the lease and read the public roster; it cannot read a DM, forge an
714
788
  * actor, provision, purge, or tamper with a stream. */
715
789
  function supervisorPermissions(space, pr) {
716
790
  const PKV = `KV_${presenceBucket(space)}`, MKV = `KV_${managerBucket(space)}`;
717
- // The three SERVED lifecycle tiers (manager.ts serveControl): subscribe `ctl.<tier>.*.*` (queue-grouped,
718
- // the owner+actor caller slots widened from one token to two) and reply on the bounded
719
- // `ctl.<tier>.<owner>.<actor>.reply.<uuid>` subtree. Plain NATS request/reply no `$JS.ACK`.
720
- const tiers = [CONTROL_PRIVILEGED, CONTROL_SELF_SERVICE, CONTROL_ADMIN];
721
- const ctlServe = tiers.map((t) => controlServiceSubject(space, t, "*", "*")); // ctl.<tier>.*.*
722
- const ctlReplies = tiers.map((t) => `${controlServiceSubject(space, t, "*", "*")}.reply.>`);
791
+ // 1d: the supervisor no longer serves the manager control tiers the manager's control surface
792
+ // is its v0.4 `service` endpoint, served on a SEPARATE connection under its own `endpoint-serve`
793
+ // credential (its rails are that credential's grant, not the supervisor's). The supervisor now
794
+ // holds only the lease, presence, and the ONE delivery-admin call.
723
795
  return {
724
796
  pub: {
725
797
  allow: [
726
798
  "$JS.API.INFO",
727
- // Singleton manager lease (managerBucket, pre-created at `cotal up`): OPEN-ONLY bind + CAS the one
728
- // lease key (acquire/renew/release) + read it. NO STREAM.CREATE (pre-created), DELETE, or PURGE.
799
+ // Per-instance manager liveness lease (managerBucket, pre-created at `cotal up`): OPEN-ONLY bind +
800
+ // CAS this instance's own `lease.<instanceId>` key (acquire/renew/release) + read the subtree. P2
801
+ // item 3 demoted the per-space singleton to per-instance keys, so the write grant spans `lease.*`
802
+ // (every instance of this space shares this one supervisor principal — the isolation between
803
+ // instances is the logical-id/CAS boundary, not a cred boundary). NO STREAM.CREATE (pre-created),
804
+ // DELETE, or PURGE.
729
805
  `$JS.API.STREAM.INFO.${MKV}`,
730
- `$JS.API.STREAM.MSG.GET.${MKV}`, // readManagerLease + CAS-conflict kv.get (auth-mode kvm.open ⇒ MSG.GET)
731
- `$KV.${managerBucket(space)}.${MANAGER_LEASE_KEY}`, // the SINGLE lease key (create/update/delete = $KV publishes)
806
+ `$JS.API.STREAM.MSG.GET.${MKV}`, // readManagerLease (last_by_subj lease.*) + CAS-conflict kv.get
807
+ `$KV.${managerBucket(space)}.${MANAGER_LEASE_KEY}.*`, // this instance's lease.<id> key (create/update/delete = $KV publishes)
732
808
  // Presence: publish OWN key + watch the roster. Own key only (no peer-key forge — residual 3); no
733
809
  // presence-stream purge/delete (no force-offline tamper). No presence kv.get (roster is the in-memory
734
810
  // watch cache + sweep), so no STREAM.MSG.GET on presence.
@@ -737,9 +813,6 @@ function supervisorPermissions(space, pr) {
737
813
  `$JS.API.CONSUMER.CREATE.${PKV}.>`, // kv.watch ordered consumer (roster)
738
814
  `$JS.API.CONSUMER.INFO.${PKV}.>`,
739
815
  "$JS.FC.>", // ordered-consumer flow control
740
- // Control: reply to any caller on each SERVED tier (bounded). It SERVES (does not call), so no
741
- // request-publish grant and no position-1 wildcard — EXCEPT the delivery-admin rail below.
742
- ...ctlReplies,
743
816
  // The ONE control service the supervisor CALLS (D5 slice 5): the delivery daemon's privileged
744
817
  // admin rail — the manager is the class-2 renewal owner, and after re-signing the daemon creds
745
818
  // files it requests `reloadCreds` here so adoption is an explicit, auditable event. Self-scoped
@@ -748,10 +821,10 @@ function supervisorPermissions(space, pr) {
748
821
  ],
749
822
  },
750
823
  sub: {
751
- // Own reply inbox + the three served control tiers (queue-grouped) + the delivery-admin reply
752
- // subtree for its OWN requests. NO chat/inst/dlv native sub (the supervisor reads no feed), NO
753
- // broad `$JS.>`/`$KV.>` (the residual-2 read/admin path is gone).
754
- allow: [`_INBOX_${pr.connId}.>`, ...ctlServe, `${controlServiceSubject(space, CONTROL_DELIVERY_ADMIN, pr.owner, pr.actor)}.>`],
824
+ // Own reply inbox + the delivery-admin reply subtree for its OWN requests. NO chat/inst/dlv
825
+ // native sub (the supervisor reads no feed), NO manager control-tier serve (1d: that moved to
826
+ // the endpoint-serve credential), NO broad `$JS.>`/`$KV.>` (the residual-2 read/admin path is gone).
827
+ allow: [`_INBOX_${pr.connId}.>`, `${controlServiceSubject(space, CONTROL_DELIVERY_ADMIN, pr.owner, pr.actor)}.>`],
755
828
  },
756
829
  };
757
830
  }
@@ -852,21 +925,29 @@ function channelPurgerPermissions(space, pr) {
852
925
  }
853
926
  /** TEARDOWN (PR 1.5) — `cotal down -f` space teardown. The SOLE cred that keeps `STREAM.DELETE` (the
854
927
  * face-b tamper verb). `down -f` is multi-step: `connectProbe` (presence-watch + channel-registry read)
855
- * → `requestControl(CONTROL_ADMIN, ps/stop)` to politely stop the managed agents → `deleteChannels`
856
- * (channel-registry key delete + CHAT purge) → `deleteSpace` (STREAM.DELETE all 12 space streams/buckets).
928
+ * → invoke the manager's `ps` + any-mode `despawn` over the ep rails to politely stop the managed
929
+ * agents → `deleteChannels`
930
+ * (channel-registry key delete + CHAT purge) → `deleteSpace` (STREAM.DELETE all 13 space streams/buckets).
857
931
  * So it reads state, CALLS admin control, deletes channels, and deletes streams — but NEVER reads a
858
932
  * DM/DLV body, posts chat, or forges. Isolated here so no standing operator/provisioner/supervisor cred
859
933
  * can delete a stream; a leaked teardown can wipe a space you own + stop its agents (that IS its job),
860
934
  * nothing else. Minted ephemerally per teardown from the local trust material (same-checkout `down -f`). */
861
935
  function teardownPermissions(space, pr) {
936
+ // The ep-rail mirror of the admin deploy tier (1c.2c): teardown reads `ps` and stops owned agents
937
+ // it did not spawn (any-mode despawn) - the admin instrument set. Lifecycle-keyed, so a uid is
938
+ // required at mint (fail-loud).
939
+ const ep = instrumentEpRows(space, pr, "admin");
862
940
  const CHAT = chatStream(space);
863
941
  const PKV = `KV_${presenceBucket(space)}`, CHKV = `KV_${channelBucket(space)}`;
864
- // deleteSpace() deletes EVERY stream + KV bucket setup creates (5 streams + 7 buckets); each needs
865
- // INFO (jsm existence) + DELETE. This is the ONLY cred that holds STREAM.DELETE (face-b isolated here).
942
+ // deleteSpace() deletes EVERY stream + KV bucket setup creates (5 streams + 7 KV buckets + the
943
+ // artifact object store = 13); each needs INFO (jsm existence) + DELETE. This is the ONLY cred that
944
+ // holds STREAM.DELETE (face-b isolated here). This list and deleteSpace()'s own array must agree:
945
+ // a stream in one and not the other is either an undeletable leak or a grant for nothing.
866
946
  const del = [
867
947
  CHAT, dmStream(space), taskStream(space), inboxStream(space), dlvStream(space),
868
948
  PKV, CHKV, `KV_${membersBucket(space)}`, `KV_${aclBucket(space)}`,
869
949
  `KV_${membershipBucket(space)}`, `KV_${deliveryBucket(space)}`, `KV_${managerBucket(space)}`,
950
+ objectStoreStream(artifactBucket(space)),
870
951
  ].flatMap((s) => [`$JS.API.STREAM.INFO.${s}`, `$JS.API.STREAM.DELETE.${s}`]);
871
952
  return {
872
953
  pub: {
@@ -880,44 +961,106 @@ function teardownPermissions(space, pr) {
880
961
  `$JS.API.CONSUMER.CREATE.${CHKV}.>`,
881
962
  `$JS.API.CONSUMER.INFO.${CHKV}.>`,
882
963
  "$JS.FC.>", // ordered-consumer flow control
883
- // Stop the managed agents via the admin control tier (ps + per-agent stop).
884
- controlServiceSubject(space, CONTROL_ADMIN, pr.owner, pr.actor),
964
+ // Stop the managed agents over the v0.4 ep rails only (ps + any-mode despawn) — the admin
965
+ // instrument set. The manager `ctl` rail is deleted (1d).
966
+ ...ep.pub,
885
967
  ...del,
886
968
  // deleteChannels/clearChannel: purge the channel's chat messages + delete its registry key.
887
969
  `$JS.API.STREAM.PURGE.${CHAT}`,
888
970
  `$KV.${channelBucket(space)}.>`,
889
971
  ],
890
972
  },
891
- // Own inbox (connectProbe presence-watch delivery + JS API responses) + the BOUNDED admin control-reply
892
- // subtree: the agent-stop step is `requestControl(CONTROL_ADMIN, ps/stop)`, whose reply rides
893
- // `ctl.admin.<id>.reply.<uuid>` (NOT `_INBOX`) without this grant those calls hang and the agents are
894
- // never stopped before the streams are deleted.
895
- sub: { allow: [`_INBOX_${pr.connId}.>`, `${controlServiceSubject(space, CONTROL_ADMIN, pr.owner, pr.actor)}.reply.>`] },
973
+ // Own inbox (connectProbe presence-watch delivery + JS API responses) + the ep reply rail (the
974
+ // ps + any-mode despawn calls reply there). The `ctl.admin.<id>.reply.>` subtree is gone (1d).
975
+ sub: { allow: [`_INBOX_${pr.connId}.>`, ...ep.sub] },
896
976
  };
897
977
  }
898
- /** CONTROL-CALLER (PR 1.5) — the operator's lifecycle commands (`cotal ps/start/stop/attach`,
899
- * `manager/commands.ts`). It CALLS ONE of the running manager's control tiers and reads the bounded
900
- * reply on its own inbox. That is ALL — no `$JS`, no `$KV`, no chat/DM: it forges nothing, reads no body.
978
+ /** CONTROL-CALLER (PR 1.5; ep-only since 1d) — the operator's lifecycle commands
979
+ * (`cotal ps/start/stop/attach`, `manager/commands.ts`). It invokes the manager's v0.4 service
980
+ * endpoint and reads the bounded reply on the ep reply rail. That is ALL — no `$JS`, no `$KV`,
981
+ * no chat/DM: it forges nothing, reads no body.
901
982
  *
902
- * The tiers are SPLIT because the manager's control authz is SUBJECT-gated, NOT caller-identity-gated
903
- * (`manager.ts authorizeNamed`: `if (admin) return undefined` ANY caller reaching `ctl.<admin>` may
904
- * stop/attach ANY agent; the privileged tier restricts named ops to the caller's OWN spawned child).
905
- * So the BROKER grant is load-bearing: holding `ctl.<admin>.<id>` pub *is* cross-agent stop/attach
906
- * powerthe manager does not re-narrow it by `req.from.id`. Therefore:
907
- * • `control-caller-privileged` (ps/start) gets ONLY `ctl.<privileged>.<id>` structurally barred from
908
- * cross-agent admin ops by the broker. This is the high-frequency path; it never needs admin reach.
909
- *`control-caller-admin` (stop/attach) gets ONLY `ctl.<admin>.<id>` — it genuinely needs cross-agent
910
- * reach. Its containment is NOT a manager re-check (there is none): it is the broker gating the admin
911
- * subject + the cred being ephemeral (mint one request → disconnect, from the local signing seed). */
912
- function controlCallerPermissions(space, pr, tier) {
913
- const reqSubject = controlServiceSubject(space, tier, pr.owner, pr.actor);
983
+ * The tiers stay SPLIT because the BROKER grant is load-bearing (the 1c decision): an any-mode
984
+ * despawn/attach row *is* cross-agent reachthe manager maps mode `any` to its admin
985
+ * authorization path, so which ROWS an instrument holds is the tier boundary. Therefore:
986
+ * `control-caller-privileged` (ps/start) holds the manager reads + untargeted `spawn` +
987
+ * `define-persona`structurally barred from cross-agent ops (no any-mode row). This is the
988
+ * high-frequency path; it never needs admin reach.
989
+ * • `control-caller-admin` (stop/attach) adds the any-mode despawn/attach rows + the
990
+ * `manager.admin` family — it genuinely needs cross-agent reach. Its containment is the
991
+ * broker gating the any-mode rows + the cred being ephemeral (mint one request
992
+ * disconnect, from the local signing seed); on a user mesh the manager's serve-time ledger
993
+ * re-check sits on top. */
994
+ function controlCallerPermissions(space, pr, epTier, opts = {}) {
995
+ // 1d: the manager `ctl` rail is gone — an operator instrument holds ONLY its v0.4 ep rows (the
996
+ // tier-matched request set, the reply rail, describe, the one epc fetch). The `epTier` selects
997
+ // privileged (ps/start reads) vs admin (any-mode stop/attach) exactly as the ctl tier did.
998
+ //
999
+ // B6 / `--on <instanceId>`: these instruments are ONE-SHOT, minted per control call, and the
1000
+ // resolve that pins the instance happens BEFORE the mint. So the caller can hand the exact
1001
+ // instance id down and get the exact `ep.inst.<endpoint>.<iid>.<command>` row for THIS invocation
1002
+ // and nothing else — the least-privilege issuance, with no standing wildcard anywhere. The
1003
+ // `extra` seam is the same one the deployer's owner-equality `launch` row already rides, and the
1004
+ // emitter's `if (cap.instanceId)` branch validates the token, so a malformed id fails loud at
1005
+ // mint rather than widening a subject.
1006
+ const ep = instrumentEpRows(space, pr, epTier, opts.endpointCapabilities ?? []);
1007
+ return {
1008
+ // The PRIVILEGED tier (the `cotal ps` instrument) also carries the SCOPED §13.9 records read the
1009
+ // class scatter's freeze rides (P2 item 3): `freezeExpectedSet` enumerates `svc.<endpoint>.*.spec`
1010
+ // and LEADER-reads each frozen slot's svc spec/status before it scatters `ps` on the `all` rail.
1011
+ // The admin tier (stop/attach) never scatters, so it gets no records read.
1012
+ pub: { allow: epTier === "privileged" ? [...ep.pub, ...scatterFreezeReadRows(space)] : ep.pub },
1013
+ sub: { allow: [`_INBOX_${pr.connId}.>`, ...ep.sub] },
1014
+ };
1015
+ }
1016
+ /** The SCOPED §13.9 records-read rows the class-scatter freeze rides (P2 item 3, `cotal ps`): the
1017
+ * `svc.*` enumeration consumer + the leader-served per-slot spec/status read of the endpoint's
1018
+ * `svc` registry — a READ of exactly the service-registration keys, no write, no other bucket. A
1019
+ * new D32 matrix row. The keyed Direct Get is subject-PINNED to `svc.>`; the enumeration consumer
1020
+ * and the leader `STREAM.MSG.GET` are STREAM-scoped because the requested key rides the PAYLOAD
1021
+ * (which a subject grant cannot narrow) — the SAME NAMED RESIDUAL every records reader already
1022
+ * accepts (the provisioner, the lifecycle/serve executors): for this EPHEMERAL one-shot instrument's
1023
+ * lifetime it can READ (never write) any records-store row — registration metadata, no secrets. */
1024
+ function scatterFreezeReadRows(space) {
1025
+ const REC = recordsBucket(space);
1026
+ return [
1027
+ // The `svc.<e>.*.spec` enumeration is a `STREAM.INFO` carrying a `subjects_filter` — ONE
1028
+ // read-only metadata verb. It replaced a `kv.keys()` that rode an ordered ephemeral consumer and
1029
+ // therefore needed CONSUMER.CREATE/INFO/DELETE on this bucket: three consumer-lifecycle verbs to
1030
+ // list keys, on a credential that only ever wanted to read them.
1031
+ //
1032
+ // MEASURED, not assumed: with the enumeration converted and this row absent, the static/operator
1033
+ // `cotal ps` is refused on `$JS.API.STREAM.INFO.KV_<records>` — a path that works today. The
1034
+ // conversion and this row land TOGETHER or the operator path regresses.
1035
+ `$JS.API.STREAM.INFO.KV_${REC}`,
1036
+ // The per-slot spec/status reads (`freezeExpectedSet` + the registration reconcile) are
1037
+ // leader-served `STREAM.MSG.GET` — stream-scoped (NAMED RESIDUAL above), plus the subject-pinned
1038
+ // keyed Direct Get form for any direct-aware read path (scoped to the `svc.` registry prefix).
1039
+ `$JS.API.STREAM.MSG.GET.KV_${REC}`,
1040
+ `$JS.API.DIRECT.GET.KV_${REC}.$KV.${REC}.svc.>`,
1041
+ ];
1042
+ }
1043
+ /** The v0.4 ep-rail rows of an operator INSTRUMENT credential (the 1c grant-migration table's
1044
+ * admin row): the tier-matched {@link operatorInstrumentCapabilities} request rows + the caller's
1045
+ * reply rail, the wildcard `describe` form, and the ONE subject-scoped §13.7 contract-store fetch
1046
+ * row (the same shape the agent baseline holds; the D32 audit's single exemption). These mirror
1047
+ * the instrument's ctl tier onto the ep rails during dual-serve; at 1d the ctl row disappears and
1048
+ * these ARE the instrument. The caller triple pins the instrument's own mint-time lifecycle uid
1049
+ * ({@link MintPrincipal.lifecycleUid}) — REQUIRED here: without it the reply rail cannot be pinned
1050
+ * and the mint fails loud rather than emit a triple-less (unfenced) caller surface. `extra` lets a
1051
+ * profile append its tier-refined additions (the user-mode deployer's owner-equality `launch`). */
1052
+ function instrumentEpRows(space, pr, tier, extra = []) {
1053
+ if (!pr.lifecycleUid)
1054
+ throw new Error(`permissionsFor: an operator instrument's ep caller rows are lifecycle-keyed (SPEC 13.1/13.2) - mint with opts.lifecycleUid (mintLifecycleUid()) so the reply rail pins the instrument's own incarnation`);
1055
+ const epCaller = { owner: pr.owner, actor: pr.actor, uid: pr.lifecycleUid };
1056
+ const rows = epCallerGrantRows(space, [...operatorInstrumentCapabilities(tier), ...extra], epCaller);
914
1057
  return {
915
- pub: { allow: [reqSubject] }, // exactly ONE tier — ps/start XOR stop/attach
916
- // Own inbox + the BOUNDED control-reply subtree. `requestControl` issues a `noMux` request whose reply
917
- // rides `ctl.<tier>.<id>.reply.<uuid>` (UNDER its own request subject, NOT `_INBOX`), so it must be able
918
- // to subscribe that subtree — without this grant the reply sub is broker-denied and every control call
919
- // hangs to timeout (endpoint.ts:803-806 predicts exactly this).
920
- sub: { allow: [`_INBOX_${pr.connId}.>`, `${reqSubject}.reply.>`] },
1058
+ pub: [
1059
+ epDescribeAllGrantRow(space, epCaller),
1060
+ `$JS.API.DIRECT.GET.${epcStreamName(space)}.${spacePrefix(space)}.epc.>`,
1061
+ ...rows.pub,
1062
+ ],
1063
+ sub: rows.sub,
921
1064
  };
922
1065
  }
923
1066
  /** ENDPOINT-SERVE (v0.4, SPEC §13.9 "Serve grants") — the per-instance serve credential:
@@ -970,23 +1113,34 @@ function endpointServePermissions(space, pr, opts) {
970
1113
  }
971
1114
  /** DEPLOYER (PR 1.5) — the `cotal spawn -f` manifest-deploy authority. `spawn -f` drives ONE
972
1115
  * `connectProbe` endpoint that both READS live state (roster/presence watch, channel registry,
973
- * membership feed, manager-singleton lease) AND control-CALLS the running manager's admin tier
974
- * (`launch` + `ps` readiness both `CONTROL_ADMIN`). Those interleave on one connection, so a strict
975
- * 3-connection split would only refactor `live.ts` for marginal gain; `deployer` is that one coherent,
1116
+ * membership feed, manager-singleton lease) AND invokes the running manager's `launch` + `ps`
1117
+ * readiness over the v0.4 ep rails. Those interleave on one connection, so a strict 3-connection
1118
+ * split would only refactor `live.ts` for marginal gain; `deployer` is that one coherent,
976
1119
  * ephemeral deploy cred. It is the SOLE profile that combines reads + admin-control — NOT a template a
977
1120
  * 4th command should reach for (revisit the connection split before adding a second such caller).
978
1121
  *
979
1122
  * Boundaries (all enforced by omission / default-deny): NO self-post (`chat`/`inst`/`svc`), NO `$JS.>`,
980
1123
  * NO `STREAM.DELETE`/`PURGE`/`UPDATE`, NO DM/DLV/TASK `CONSUMER.CREATE` (no body-read surface), NO `$KV`
981
- * writes (channel seeding rides a SEPARATE `channel-writer` cred), admin tier ONLY (no privileged, no
982
- * serve). It holds `ctl.<admin>.<id>` because manifest launch/ps genuinely need the admin tier — and
983
- * that IS real cross-agent power: the manager's admin authz is subject-gated, not caller-identity-gated
984
- * (`authorizeNamed`: `if (admin) return undefined`), so holding the admin pub grant lets it stop/attach/
985
- * launch ANY agent, with no manager-side `req.from.id` re-check. The BROKER gating that subject is the
986
- * boundary. Containment is therefore the LIFETIME, not a manager re-check: minted from LOCAL same-checkout
987
- * auth for one `spawn -f`, memory-only, dropped after deploy. If it is ever persisted, handed to
988
- * user-supplied `--creds`, or reused as a general "read + admin" cred, revisit. */
989
- function deployerPermissions(space, pr, tier = CONTROL_ADMIN) {
1124
+ * writes (channel seeding rides a SEPARATE `channel-writer` cred). It holds the admin INSTRUMENT ep
1125
+ * set (any-mode despawn/attach + the manager.admin family + reads) because manifest launch/ps
1126
+ * genuinely need cross-agent reach — and that IS real power: the manager's any-mode authz is
1127
+ * subject-gated (the any-mode ep row is mintable only under operator policy), so holding it lets it
1128
+ * stop/attach/launch ANY agent. The BROKER gating that row is the boundary. Containment is therefore
1129
+ * the LIFETIME, not a manager re-check: minted from LOCAL same-checkout auth for one `spawn -f`,
1130
+ * memory-only, dropped after deploy. If it is ever persisted, handed to user-supplied `--creds`, or
1131
+ * reused as a general "read + admin" cred, revisit. */
1132
+ function deployerPermissions(space, pr, epTier = "admin", opts = {}) {
1133
+ // The v0.4 ep rows of the deploy authority: static (admin) deploys carry the admin instrument
1134
+ // set; the user-mode deployer VIEW (privileged) carries the privileged set PLUS an untargeted
1135
+ // `launch` row — its launch stays owner-equality-authorized (the manager's ledger-derived admin
1136
+ // flag is false for a spawn-scoped deployer), exactly the v0.3 user-mode privileged-tier launch.
1137
+ // B6 / `--on`: the per-invocation pin APPENDS to this profile's standing set, never replaces it -
1138
+ // the privileged deployer view keeps its owner-equality `launch` row and additionally gets the one
1139
+ // exact `ep.inst.<endpoint>.<iid>.<command>` row for the instance this deploy resolved.
1140
+ const pinned = opts.endpointCapabilities ?? [];
1141
+ const ep = epTier === "admin"
1142
+ ? instrumentEpRows(space, pr, "admin", pinned)
1143
+ : instrumentEpRows(space, pr, "privileged", [{ endpoint: BASELINE_LIFECYCLE_ENDPOINT, command: "launch" }, ...pinned]);
990
1144
  const PKV = `KV_${presenceBucket(space)}`, CHKV = `KV_${channelBucket(space)}`;
991
1145
  const MSHIP = `KV_${membershipBucket(space)}`, MGRKV = `KV_${managerBucket(space)}`;
992
1146
  const DLVKV = `KV_${deliveryBucket(space)}`;
@@ -1017,16 +1171,15 @@ function deployerPermissions(space, pr, tier = CONTROL_ADMIN) {
1017
1171
  ...kvPointRead(MGRKV), // manager-singleton lease keyed read (waitManagerReady) — point-get, NO write, NO watch
1018
1172
  ...kvPointRead(DLVKV), // delivery-lease keyed read (preserve-state quiescence proof) — point-get, NO write, NO watch
1019
1173
  "$JS.FC.>", // ordered-consumer flow control
1020
- // ONE control tier — launch + ps readiness. Static operator deploy creds ride CONTROL_ADMIN
1021
- // (the historical shape); the user-mode `deployer` VIEW rides CONTROL_PRIVILEGED so the
1022
- // manager's owner-equality launch authorization governs (never the admin-tier bypass).
1023
- controlServiceSubject(space, tier, pr.owner, pr.actor),
1174
+ // 1d: launch + ps readiness ride the v0.4 ep rows only (the manager `ctl` rail is gone).
1175
+ // Static deploys carry the admin instrument set; the user-mode deployer VIEW carries the
1176
+ // privileged set + an owner-equality `launch` row (the manager's ledger-derived admin flag
1177
+ // is false for a spawn-scoped deployer, so its launch stays owner-equality-authorized).
1178
+ ...ep.pub,
1024
1179
  ],
1025
1180
  },
1026
- // Own inbox (presence/registry watch delivery + JS API responses) + the BOUNDED control-reply
1027
- // subtree for the same tier: `requestControl(tier, launch/ps)` subscribes `ctl.<tier>.<id>.reply.<uuid>`,
1028
- // so without this grant the launch + ps-readiness calls hang to timeout.
1029
- sub: { allow: [`_INBOX_${pr.connId}.>`, `${controlServiceSubject(space, tier, pr.owner, pr.actor)}.reply.>`] },
1181
+ // Own inbox (presence/registry watch delivery + JS API responses) + the ep reply rail.
1182
+ sub: { allow: [`_INBOX_${pr.connId}.>`, ...ep.sub] },
1030
1183
  };
1031
1184
  }
1032
1185
  /** The ephemeral PURGER permission set (closure (ii), residual 2) — minted per-purge inside the daemon's
@@ -1068,8 +1221,14 @@ function purgerPermissions(space, pr) {
1068
1221
  *
1069
1222
  * `$JS` is an ENUMERATED allow-list, never `$JS.>`: STREAM.CREATE + INFO for the space streams/buckets,
1070
1223
  * DM/DLV/TASK consumer CREATE/DURABLE.CREATE/INFO — and deliberately NO `MSG.NEXT`/`MSG.GET`/`ACK` on
1071
- * DM/DLV (it creates the bind-only mailbox but never reads it), NO STREAM.DELETE/PURGE/UPDATE/MSG.DELETE
1072
- * (it provisions, it does not tear down or tamper). KV value-writes are scoped to exactly the two
1224
+ * DM/DLV (it creates the bind-only mailbox but never reads it), and NO STREAM.DELETE/PURGE/MSG.DELETE
1225
+ * (it provisions, it does not tear down). STREAM.UPDATE is held on EXACTLY four streams and no others:
1226
+ * the three TTL'd KV buckets (presence + the two leases, #286 — an existing bucket's `max_age` cannot be
1227
+ * fixed by `kvm.create`, so reconciling a pre-TTL deployment requires updating it) and the records store.
1228
+ * Stated positively on purpose: this docblock previously read "NO …/UPDATE", which was already untrue of
1229
+ * the records stream and became untrue of the buckets, and a comment that denies a credential's real
1230
+ * power is worse than none — it is the document a reader trusts instead of checking. KV value-writes are
1231
+ * scoped to exactly the two
1073
1232
  * registries provisioning touches: the read-ACL bucket (`commitAcl`) and the channel registry (seed). */
1074
1233
  function provisionerPermissions(space, pr) {
1075
1234
  const CHAT = chatStream(space), DM = dmStream(space), TASK = taskStream(space);
@@ -1077,15 +1236,52 @@ function provisionerPermissions(space, pr) {
1077
1236
  // Every backing stream the provisioner pre-creates — the 5 message streams + the KV buckets (a bucket's
1078
1237
  // backing stream is `KV_<bucket>`). `managerBucket` is now pre-created here too (so the supervisor binds
1079
1238
  // its lease open-only); members/membership/delivery are written by other creds but created at setup here.
1239
+ // The §13.12 AUTHORITY stores (records + auth + the P2 item 6 session ledger) join the list for the
1240
+ // STATIC manager's start-time `createEndpointStreams` (a superset of ensureAuthorityStores +
1241
+ // createSessionsStore): create-or-verify only — the provisioner holds NO value-write on any of them
1242
+ // (lifecycle state moves through the key-pinned `lifecycle-executor` cred; session rows through the
1243
+ // scoped `session-ledger` cred).
1080
1244
  const buckets = [
1081
1245
  presenceBucket, channelBucket, membersBucket, aclBucket, membershipBucket, deliveryBucket, managerBucket,
1246
+ recordsBucket, epAuthBucket, sessionsBucket,
1082
1247
  ].map((b) => `KV_${b(space)}`);
1083
1248
  // STREAM.CREATE + INFO for each (idempotent setup at `cotal up`; CREATE is create-if-matching, INFO covers
1084
- // the client's existence checks). NO DELETE/PURGE/UPDATE — provisioning never tears a stream down.
1085
- const streamSetup = [CHAT, DM, TASK, INBOX, DLV, ...buckets].flatMap((s) => [
1249
+ // the client's existence checks). NO DELETE/PURGE — provisioning never tears a stream down.
1250
+ // The §13.7 CONTRACT store (EPC) joins the list for the static manager's start-time
1251
+ // `ensureContractStore` (P2 item 1, 1c): create-or-verify only — the provisioner holds no
1252
+ // artifact-publish grant on it (publication rides the scoped endpoint-serve executor).
1253
+ // The seven §13.12 ENDPOINT streams join the list (P2 item 2): spawn-as-action makes the manager
1254
+ // the first EPF (goal facts) + EPE (progress) writer, and nothing provisioned the endpoint streams
1255
+ // before (no manager code wrote to them), so `createEndpointStreams` now runs at the manager's
1256
+ // start-time ensure over this provisioner. Create-or-verify only (idempotent, fail-loud on drift);
1257
+ // the provisioner holds no value-write on any of them (goal facts ride the scoped goal-writer cred).
1258
+ const endpointStreams = [epjStreamName, epfStreamName, epeStreamName, eptReqStreamName, eprStreamName, eptStreamName, epwStreamName].map((f) => f(space));
1259
+ // The artifact Object Store joins the list: `setupSpaceStreams` creates it, and under auth mode the
1260
+ // provisioner is the cred doing that creating. Its backing stream is `OBJ_<bucket>` - named
1261
+ // explicitly, because `$O.<bucket>.>` is outside the `cotal.<space>.>` grammar and no space-prefix
1262
+ // grant reaches it. CREATE + INFO only: the provisioner never publishes an object, never creates a
1263
+ // consumer on it, and never deletes it. That confinement is load-bearing rather than tidy - the
1264
+ // object-store client reads by creating an ephemeral PUSH consumer with a caller-chosen
1265
+ // `deliver_subject`, so a CONSUMER.CREATE here would be an exporter of every artifact in the space.
1266
+ const OBJ = objectStoreStream(artifactBucket(space));
1267
+ const streamSetup = [CHAT, DM, TASK, INBOX, DLV, epcStreamName(space), OBJ, ...endpointStreams, ...buckets].flatMap((s) => [
1086
1268
  `$JS.API.STREAM.CREATE.${s}`,
1087
1269
  `$JS.API.STREAM.INFO.${s}`,
1088
1270
  ]);
1271
+ // #286: STREAM.UPDATE on EXACTLY the three TTL'd KV streams (presence + the two leases). `kvm.create`
1272
+ // never updates an existing bucket's config, so a bucket created by a cotal that predates the `max_age`
1273
+ // TTL keeps NO expiry forever — dead presence records (and stale leases) never age out. `setupSpaceStreams`
1274
+ // reconciles their `max_age` via STREAM.UPDATE at every `cotal up`, which needs this grant. Scoped to these
1275
+ // three streams only — the durable streams (chat/dm/task/inbox/dlv, channel/members/acl/membership
1276
+ // registries) are never updated — and still NO DELETE/PURGE. The supervisor profile keeps its full UPDATE
1277
+ // denial; this widening is provisioning-only.
1278
+ // Derived from the SAME inventory that creates and reconciles them, not a third hand-kept copy.
1279
+ // Review found this list was the last independent one: a fourth TTL'd bucket added to `ttlBuckets`
1280
+ // would be created and reconciled correctly and then die on a permissions violation here, because
1281
+ // the grant never learned about it. Same defect one seam out — a bucket the code knows to maintain
1282
+ // and the credential is not allowed to.
1283
+ const ttlStreams = ttlBuckets(space).map(([bucket]) => `KV_${bucket}`);
1284
+ const streamReconcile = ttlStreams.map((s) => `$JS.API.STREAM.UPDATE.${s}`);
1089
1285
  // DM/DLV/TASK durable pre-create (bind-only mailboxes): both the new-API CREATE and legacy DURABLE.CREATE
1090
1286
  // forms (the client's consumer-add path varies by version), plus INFO (the add returns ConsumerInfo).
1091
1287
  // NO MSG.NEXT/MSG.GET/ACK — the provisioner creates the consumer but MUST NOT read its body.
@@ -1099,6 +1295,7 @@ function provisionerPermissions(space, pr) {
1099
1295
  allow: [
1100
1296
  "$JS.API.INFO",
1101
1297
  ...streamSetup,
1298
+ ...streamReconcile,
1102
1299
  ...consumerCreate,
1103
1300
  // KV value-writes — exactly the two registries provisioning writes: the agent read-ACL registry
1104
1301
  // (`commitAcl` at provision) and the channel registry (seed defaults at `cotal up`, channel admin).
@@ -1114,6 +1311,22 @@ function provisionerPermissions(space, pr) {
1114
1311
  `$JS.API.DIRECT.GET.KV_${aclBucket(space)}.>`, // keyed get: `.>` (the key rides the subject)
1115
1312
  `$JS.API.STREAM.MSG.GET.KV_${channelBucket(space)}`,
1116
1313
  `$JS.API.DIRECT.GET.KV_${channelBucket(space)}.>`, // keyed get: `.>` (the key rides the subject)
1314
+ // The Unit B static-manager start path: `ensureAuthorityStores` UPDATEs the records store's
1315
+ // deny-flags exactly once at fresh creation (create → update → verify), and the boot
1316
+ // reconciliation sweep enumerates the manager's slot rows (`keys()` → an ordered consumer)
1317
+ // then reads each slot BODY (phase/uid/actor) to plan resume — the reads ride the
1318
+ // stream-scoped MSG.GET residual named below (records lifecycle metadata, no secrets).
1319
+ `$JS.API.STREAM.UPDATE.KV_${recordsBucket(space)}`,
1320
+ `$JS.API.CONSUMER.CREATE.KV_${recordsBucket(space)}.>`,
1321
+ `$JS.API.CONSUMER.INFO.KV_${recordsBucket(space)}.>`,
1322
+ `$JS.API.CONSUMER.DELETE.KV_${recordsBucket(space)}.>`,
1323
+ // ...and reads the slot-mapping rows so the sweep can plan resume actions. The KV client
1324
+ // reads a lazily-bound bucket via leader-served `STREAM.MSG.GET` — stream-scoped, NOT
1325
+ // key-scoped (the requested key rides the PAYLOAD). NAMED RESIDUAL: for its one-shot
1326
+ // lifetime the provisioner can READ (never write) any records-store row — lifecycle
1327
+ // metadata, no secrets. The keyed Direct Get grant stays for direct-aware read paths.
1328
+ `$JS.API.STREAM.MSG.GET.KV_${recordsBucket(space)}`,
1329
+ `$JS.API.DIRECT.GET.KV_${recordsBucket(space)}.$KV.${recordsBucket(space)}.${STATIC_SLOT_PREFIX}.>`,
1117
1330
  ],
1118
1331
  },
1119
1332
  // Replies only: every stream/consumer/KV-create PubAck and JS API response lands on the per-id inbox.
@@ -1121,6 +1334,185 @@ function provisionerPermissions(space, pr) {
1121
1334
  sub: { allow: [`_INBOX_${pr.connId}.>`] },
1122
1335
  };
1123
1336
  }
1337
+ /** The ephemeral, LIFECYCLE-PINNED §13.1 state-write permission set for the STATIC manager's
1338
+ * lifecycle executor (Unit B). One credential per lifecycle OPERATION (activation, terminal,
1339
+ * renewal ledger append): every grant names exactly ONE incarnation's keys — the alias head,
1340
+ * the uid reservation, the manager slot row, the issuance gate, and the `cred.<uid>.>` ledger
1341
+ * family — so a leaked executor cred can move one incarnation's state machine and nothing else.
1342
+ *
1343
+ * Reads: records reads ride the keyed Direct Get form (the key is ON the subject, so the read
1344
+ * grant stays key-pinned); the auth store is leader-served (`allow_direct=false`), so its reads
1345
+ * are body-selected `STREAM.MSG.GET` — stream-scoped, NOT key-scoped (the requested key rides
1346
+ * the PAYLOAD, which a subject grant cannot see). NAMED RESIDUAL: for its one-shot lifetime the
1347
+ * executor can READ (never write) other rows in the auth store. */
1348
+ function lifecycleExecutorPermissions(space, pr, pin) {
1349
+ const REC = recordsBucket(space), AUTH = epAuthBucket(space);
1350
+ // ALL keys DERIVED here from the pin coordinates — the slot key is `staticSlotKey(owner, alias)`,
1351
+ // NOT a caller-supplied literal, so a mis-constructed pin can only ever name ONE coherent
1352
+ // incarnation's rows (guard the core: the profile enforces the "one incarnation" promise, it
1353
+ // does not merely assert it). The builders throw on any non-KV-safe segment.
1354
+ const recordKeys = [lifecycleHeadKey(pin.owner, pin.actor), uidReservationKey(pin.lifecycleUid), staticSlotKey(pin.owner, pin.alias)];
1355
+ return {
1356
+ pub: {
1357
+ allow: [
1358
+ "$JS.API.INFO",
1359
+ // Records-store CAS writes — the value-publish carries the key on the subject, so each
1360
+ // grant names exactly one of this incarnation's keys.
1361
+ ...recordKeys.map((k) => `$KV.${REC}.${k}`),
1362
+ // Auth-store CAS writes — this incarnation's gate + its cred-ledger family (renewals
1363
+ // append rows here; the terminal's B1 revoke CASes them).
1364
+ `$KV.${AUTH}.${issuanceGateKey(pin.lifecycleUid)}`,
1365
+ `$KV.${AUTH}.cred.${pin.lifecycleUid}.>`,
1366
+ // Keyed Direct Get reads of the same records keys (key-pinned) for direct-aware read
1367
+ // paths, PLUS the leader-served `STREAM.MSG.GET` the lazily-bound KV client actually
1368
+ // uses — stream-scoped, NOT key-scoped (the requested key rides the PAYLOAD, which a
1369
+ // subject grant cannot see). NAMED RESIDUAL: for its one-shot lifetime the executor can
1370
+ // READ (never write) other rows in BOTH authority stores — lifecycle metadata, no
1371
+ // secrets; every WRITE stays key-pinned above.
1372
+ ...recordKeys.map((k) => `$JS.API.DIRECT.GET.KV_${REC}.$KV.${REC}.${k}`),
1373
+ `$JS.API.STREAM.MSG.GET.KV_${REC}`,
1374
+ `$JS.API.STREAM.MSG.GET.KV_${AUTH}`,
1375
+ ],
1376
+ },
1377
+ sub: { allow: [`_INBOX_${pr.connId}.>`] },
1378
+ };
1379
+ }
1380
+ /** The ephemeral, ENDPOINT-INSTANCE-PINNED endpoint-serve executor permission set (P2 item 1,
1381
+ * 1a-serve): the manager mints this per registration/serve-mint op and drives the endpoint
1382
+ * registration barrier's `epgate` CAS + the mint fence's `epcred` stage/revoke THROUGH it — never
1383
+ * its standing seed/supervisor connection (critic #1's manager-specific "no seed shortcut"). Every
1384
+ * WRITE is key-pinned to exactly ONE (endpoint, instanceId): the gate `epgate.<ep>.<iid>`, its
1385
+ * serving ledger family `epcred.<ep>.<iid>.>`, and the registration's two records keys (the
1386
+ * instance's `svc` spec + the endpoint's governance head — `registerServiceInstance` drives the
1387
+ * slot-take/promote over this same connection). A leaked/mis-constructed executor can move exactly
1388
+ * one endpoint instance's serve state and nothing else. The auth store is `allow_direct=false`, so
1389
+ * reads are leader-served `STREAM.MSG.GET` (stream-scoped, NOT key-scoped — the key rides the
1390
+ * payload); enumeration of the epcred family rides an ordered `keys()` consumer. NAMED RESIDUAL:
1391
+ * for its one-shot lifetime the executor can READ (never write) other auth rows — endpoint/
1392
+ * credential metadata, no bearer bytes; every WRITE stays key-pinned. */
1393
+ /** The SELF-MEDIATED goal-writer profile (P2 item 2, spawn-as-action): exactly
1394
+ * {@link goalWriterGrants} for ITS endpoint — the goal bind + terminal facts, the goal-record KV
1395
+ * writes, and the leader-served fencing reads — plus the connection-scoped reply inbox. Disjoint
1396
+ * from the endpoint's serve credential (Q2): a serve connection carries none of these rows, so it
1397
+ * is broker-denied every goal write. */
1398
+ function goalWriterPermissions(space, pr, pin) {
1399
+ const g = goalWriterGrants(space, pin.endpoint, pr.connId);
1400
+ return { pub: { allow: g.publish }, sub: { allow: g.subscribe } };
1401
+ }
1402
+ /** The console/CLI per-session CALLER rows (P2 item 6): RAILS-ONLY for ONE §13.6 session — pub the
1403
+ * session's epoch-pinned `in` rail, sub its `out` rail plus the caller's own reply inbox, and
1404
+ * NOTHING else. Deliberately NO KV, NO JetStream API, NO store: the caller drives the terminal over
1405
+ * the two core-only eps subjects and never reads the session ledger, so there is no subject-blind
1406
+ * store read to widen (SPEC §13.9). The endpoint+sessionId+epoch pin the EXACT pair, so a cred for
1407
+ * session A authorizes nothing of session B (no wildcard). `epsSubject` validates every token, so a
1408
+ * malformed coordinate refuses at the mint rather than emitting a broadened subject. */
1409
+ function sessionCallerPermissions(space, pr, pin) {
1410
+ if (!pin)
1411
+ throw new Error('permissionsFor: "session-caller" requires opts.sessionCaller ({endpoint, sessionId, epoch}) - the ONE session\'s rails this cred may use');
1412
+ return {
1413
+ pub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "in")] },
1414
+ sub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "out"), `_INBOX_${pr.connId}.>`] },
1415
+ };
1416
+ }
1417
+ /** The manager's per-session SERVING rows (P2 item 6): the EXACT mirror of
1418
+ * {@link sessionCallerPermissions} with the directions swapped — sub the ONE session's epoch-pinned
1419
+ * `in` rail (caller→serving), pub its `out` rail (serving→caller), plus the connection-scoped reply
1420
+ * inbox, and NOTHING else. Same asymmetry §13.6 states: "the caller publishes `in` and subscribes
1421
+ * `out`; the serving instance the reverse".
1422
+ *
1423
+ * Deliberately NO KV and NO JetStream API — not even the session ledger. The serving side drives
1424
+ * bytes; the ledger is the standing `session-ledger` credential's, on a different connection. That
1425
+ * separation is what lets this credential be one-shot and die with its session while the durable
1426
+ * revocation authority outlives it (§13.6).
1427
+ *
1428
+ * This REPLACES a standing writer that held `eps.<endpoint>.*.<epoch>.{in,out}` and so could read
1429
+ * and write every live session's bytes at its epoch. The pin makes a credential for session A
1430
+ * authorize nothing of session B, and `epsSubject` validates every token, so a malformed
1431
+ * coordinate refuses at the mint rather than emitting a broadened subject. */
1432
+ function sessionServingPermissions(space, pr, pin) {
1433
+ if (!pin)
1434
+ throw new Error('permissionsFor: "session-serving" requires opts.sessionServing ({endpoint, sessionId, epoch}) - the ONE session\'s rails this cred may serve');
1435
+ return {
1436
+ pub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "out")] },
1437
+ sub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "in"), `_INBOX_${pr.connId}.>`] },
1438
+ };
1439
+ }
1440
+ /** The manager's SESSION-LEDGER rows (P2 item 6): the DEDICATED sessions-bucket rows and nothing
1441
+ * else — no session rail of any shape. Needs no pin: the grant carries no endpoint, epoch, or
1442
+ * session component, because §13.6's durable revocation authority is per-space, not per-session
1443
+ * (it must still be able to resolve and revoke a row after the endpoint that served it is gone).
1444
+ * {@link sessionLedgerGrants} carries the row rationale, the §13.9 subject-blindness confinement,
1445
+ * and the named `session.*` breadth residual. */
1446
+ function sessionLedgerPermissions(space, pr) {
1447
+ const g = sessionLedgerGrants(space, pr.connId);
1448
+ return { pub: { allow: g.publish }, sub: { allow: g.subscribe } };
1449
+ }
1450
+ function endpointServeExecutorPermissions(space, pr, pin) {
1451
+ const AUTH = epAuthBucket(space), REC = recordsBucket(space);
1452
+ // DERIVED from (endpoint, instanceId) via the core builders — never a caller literal.
1453
+ const gateKey = epgateKey(pin.endpoint, pin.instanceId);
1454
+ const credPrefix = epcredFamilyPrefix(pin.endpoint, pin.instanceId);
1455
+ // The registration writes this ONE instance's spec key plus the endpoint's governance head
1456
+ // (`registerServiceInstance` PHASE 1/3b: the slot-take + promote ride the SAME executor), and this
1457
+ // instance's own svc STATUS key (P2 item 3: the manager writes its CONVERGED `ready` status so it
1458
+ // is a §13.5 scatter member — `freezeExpectedSet` requires a status caught up to the current
1459
+ // registration; the write is epoch-fenced by `writeServiceStatus`).
1460
+ const recordKeys = [
1461
+ recordSpecKey(RECORD_KINDS.svc, [pin.endpoint, assertLifecycleToken(pin.instanceId, "instanceId")]),
1462
+ recordStatusKey(RECORD_KINDS.svc, [pin.endpoint, assertLifecycleToken(pin.instanceId, "instanceId")]),
1463
+ recordAtomicKey(GOVERN_HEAD, [pin.endpoint]),
1464
+ ];
1465
+ return {
1466
+ pub: {
1467
+ allow: [
1468
+ "$JS.API.INFO",
1469
+ // The key-pinned WRITE grants: the gate (provision create + barrier freeze/reopen CAS),
1470
+ // the serving ledger family (mint-fence stage + barrier revoke), and the registration's
1471
+ // two records-store keys (spec CAS + governance slot/promote). The value-publish carries
1472
+ // the key on the subject, so each grant names exactly this instance's keys.
1473
+ `$KV.${AUTH}.${gateKey}`,
1474
+ `$KV.${AUTH}.${credPrefix}.>`,
1475
+ ...recordKeys.map((k) => `$KV.${REC}.${k}`),
1476
+ // §13.7 contract-artifact publication (P2 item 1, 1c): the registration publishes the
1477
+ // endpoint's cluster document, closure manifests, and schema roots to the EPC store so
1478
+ // callers can fetch-verify-compile the registered digests. A digest subject is a SINGLE
1479
+ // hex token (`epc.<64hex>`), so the grant is the single-token `epc.*` form (matching
1480
+ // `contractPublisherGrants`), never the multi-token `epc.>`. Digest subjects cannot be
1481
+ // key-pinned pre-mint; the store's SHAPE is the defense (ensureContractStore): a digest
1482
+ // subject holds exactly one broker-immutable message (max_msgs_per_subject:1 +
1483
+ // discard-new-per-subject REJECTS a second publish regardless of the create-only header, so
1484
+ // a grant-holder CANNOT append a shadow over a published artifact), deny_delete/deny_purge
1485
+ // keep that message, and content addressing (verify-on-read, create-only-winner fallback)
1486
+ // makes a wrong-subject write unservable. NAMED RESIDUAL: for its one-shot lifetime the
1487
+ // executor can publish NEW digest-addressed artifacts at previously-unused subjects (a
1488
+ // bounded storage flood; unreferenced artifacts carry no authority) — it can NOT overwrite,
1489
+ // shadow, or replace an existing one.
1490
+ `${spacePrefix(space)}.epc.*`,
1491
+ // The lost-CAS verify-read `publishContractArtifact` runs when a re-publish (every re-up /
1492
+ // restart against an existing store) loses the create-only CAS: it fetches the recorded
1493
+ // artifact to confirm the idempotent no-op. BOTH Direct Get forms — the subject-scoped
1494
+ // `last_by_subj` (the fast path) and the bare stream form (the create-only-winner
1495
+ // `next_by_subj` fallback) — so the publish path never dies on a broker denial regardless
1496
+ // of stream config. Without this the manager exits on its SECOND boot (the tester's
1497
+ // upgrade-path regression). Reads public content-addressed artifacts only.
1498
+ `$JS.API.DIRECT.GET.${epcStreamName(space)}.${spacePrefix(space)}.epc.>`,
1499
+ `$JS.API.DIRECT.GET.${epcStreamName(space)}`,
1500
+ // Leader-served reads (the auth store is allow_direct=false): stream-scoped MSG.GET (the
1501
+ // barrier/fence read the gate + each epcred row), plus the ordered consumer the epcred
1502
+ // `keys()` enumeration binds. The records store IS direct-servable, so its reads add the
1503
+ // key-pinned DIRECT.GET forms beside the leader-served fallback. NAMED RESIDUAL: reads
1504
+ // any row in both stores for its one-shot lifetime (metadata, no bearer bytes).
1505
+ `$JS.API.STREAM.MSG.GET.KV_${AUTH}`,
1506
+ `$JS.API.CONSUMER.CREATE.KV_${AUTH}.>`,
1507
+ `$JS.API.CONSUMER.INFO.KV_${AUTH}.>`,
1508
+ `$JS.API.CONSUMER.DELETE.KV_${AUTH}.>`,
1509
+ ...recordKeys.map((k) => `$JS.API.DIRECT.GET.KV_${REC}.$KV.${REC}.${k}`),
1510
+ `$JS.API.STREAM.MSG.GET.KV_${REC}`,
1511
+ ],
1512
+ },
1513
+ sub: { allow: [`_INBOX_${pr.connId}.>`] },
1514
+ };
1515
+ }
1124
1516
  /** The ephemeral, TARGET-PINNED DEPROVISIONER permission set (#159 Part B) — the teardown counterpart
1125
1517
  * to {@link provisionerPermissions}, minted per departed agent inside the manager's `deprovision` tail
1126
1518
  * (`withProvisioner`-style: a fresh scoped cred per teardown is cheap). It deletes exactly the
@@ -1195,6 +1587,15 @@ function deliveryPermissions(space, pr) {
1195
1587
  // chat (the fan-out consumes the whole stream), so a stream-wide CHAT consumer grant is no
1196
1588
  // escalation. The catch-up ephemeral names (`cu_<owner>_<gen>`) are dynamic, so they can't be
1197
1589
  // name-pinned; CHAT-wide is correct here.
1590
+ //
1591
+ // BOTH forms, and the bare one is not redundant: a create carrying `filter_subjects` cannot encode
1592
+ // its filter in the subject, so the client publishes to the BARE `…CREATE.<CHAT>` while a named
1593
+ // consumer goes to the `.>` form. The mediated history read (`readHistory`) builds exactly that
1594
+ // multi-filter ephemeral, and with only the `.>` grant it fails with a Permissions Violation on the
1595
+ // bare subject — measured, not predicted. The observer profile already carries both for the same
1596
+ // reason. Grants the daemon nothing new in substance: it may already create any named consumer on
1597
+ // this stream and already reads all of it.
1598
+ `$JS.API.CONSUMER.CREATE.${CHAT}`,
1198
1599
  `$JS.API.CONSUMER.CREATE.${CHAT}.>`,
1199
1600
  `$JS.API.CONSUMER.DURABLE.CREATE.${CHAT}.>`,
1200
1601
  `$JS.API.CONSUMER.INFO.${CHAT}.>`,
@@ -1295,7 +1696,7 @@ function membershipObserverPermissions(accountId) {
1295
1696
  * (`auth.account.pub`). Mirrors {@link mintCreds} but issues into the system account. */
1296
1697
  export async function mintMembershipObserverCreds(auth, identity, opts = {}) {
1297
1698
  if (!auth.sys.signingSeed)
1298
- 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.");
1699
+ throw new Error("mintMembershipObserverCreds: no in-memory system-account signing seed - the observer can only be minted from a system account that is being (re)provisioned, because the $SYS seed is never persisted. Rotate the system account to mint a fresh one (`cotal down` then `cotal up --rotate-sys`); a plain re-`up` reuses the existing account and its existing creds.");
1299
1700
  const signer = fromSeed(new TextEncoder().encode(auth.sys.signingSeed));
1300
1701
  const perms = membershipObserverPermissions(auth.account.pub);
1301
1702
  // Bounded exp (D5 slice 5): the observer is `rotation-renewed` — it carries the matrix's default
@@ -1328,7 +1729,7 @@ function connectionEvictorPermissions() {
1328
1729
  * only), same fail-loud when it's absent. Paired with the observer at `up`. */
1329
1730
  export async function mintConnectionEvictorCreds(auth, identity, opts = {}) {
1330
1731
  if (!auth.sys.signingSeed)
1331
- 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.");
1732
+ throw new Error("mintConnectionEvictorCreds: no in-memory system-account signing seed - the evictor can only be minted from a system account that is being (re)provisioned, because the $SYS seed is never persisted. Rotate the system account to mint a fresh one (`cotal down` then `cotal up --rotate-sys`); a plain re-`up` reuses the existing account and its existing creds.");
1332
1733
  const signer = fromSeed(new TextEncoder().encode(auth.sys.signingSeed));
1333
1734
  // Bounded exp (D5 slice 5): `rotation-renewed`, same posture as the observer above.
1334
1735
  const validDates = userValidDates("connection-evictor", opts);
@@ -1347,6 +1748,53 @@ export async function mintConnectionEvictorCreds(auth, identity, opts = {}) {
1347
1748
  * NOTE (W4): the MEMORY resolver is one static whole-broker map, so every mutation rewrites all of
1348
1749
  * it. Concurrent add/remove of spaces needs a broker-authoritative inventory with generation/CAS
1349
1750
  * and atomic promotion above this function; this renderer is deliberately pure. */
1751
+ /**
1752
+ * Render the config for an OPEN (no-auth) broker.
1753
+ *
1754
+ * This exists so that no path reaches a listener without naming its transport. Open mode used to
1755
+ * start nats-server from bare CLI flags (`-js -sd … -p … -a …`) and never called `serverConfig` at
1756
+ * all, which meant the required `transport` union protected the auth path and was silent on the
1757
+ * open one: an operator could pass a cert and key, watch `up` print its normal banner, and get a
1758
+ * cleartext listener. That is the silent downgrade this feature exists to prevent, reachable by
1759
+ * someone who did everything right — so open mode renders a config too.
1760
+ *
1761
+ * It deliberately does NOT reuse `serverConfig`: that renders the operator, system account and
1762
+ * MEMORY resolver, none of which a no-auth broker should carry. What the two share is the thing
1763
+ * that matters — a REQUIRED transport, so the choice cannot be omitted on either path.
1764
+ *
1765
+ * IMPORTANT, and it must be said wherever open-mode TLS is surfaced to an operator: TLS ON AN
1766
+ * OPEN MESH GIVES CONFIDENTIALITY, NOT AUTHENTICATION. It hides traffic from a passive observer.
1767
+ * It does not verify who is connecting, because an open broker has no credentials to check — so
1768
+ * anyone who can reach the port still gets in, encrypted. It is a legitimate configuration for a
1769
+ * mesh crossing a network nobody controls, and it is NOT "secure" in the sense a reader will
1770
+ * assume from seeing `cotals://`. Describe it as the caveat it is rather than as a feature.
1771
+ */
1772
+ export function openServerConfig(opts) {
1773
+ const port = opts.port ?? 4222;
1774
+ const host = opts.host ?? "127.0.0.1";
1775
+ return `# Generated by \`cotal up\` - do not edit by hand.
1776
+ host: ${host}
1777
+ port: ${port}
1778
+ max_control_line: 65536
1779
+ ${renderTlsBlock(opts.transport)}jetstream { store_dir: ${JSON.stringify(opts.storeDir)} }
1780
+ `;
1781
+ }
1782
+ /** The one place a `tls{}` block is produced, shared by both broker renderers.
1783
+ *
1784
+ * `allow_non_tls` is deliberately never emitted. It is a TOP-LEVEL knob (nested inside `tls{}`
1785
+ * nats-server rejects it as an unknown field), and it turns the listener into mixed mode: INFO
1786
+ * then advertises `tls_available` instead of `tls_required`, and a client that declines to
1787
+ * upgrade is served in cleartext — precisely the credential exposure this transport closes.
1788
+ * There is no supported migration mode; enabling TLS is all-or-nothing. */
1789
+ function renderTlsBlock(transport) {
1790
+ if (transport.kind === "plaintext")
1791
+ return "";
1792
+ return `tls {
1793
+ cert_file: ${JSON.stringify(transport.certFile)}
1794
+ key_file: ${JSON.stringify(transport.keyFile)}
1795
+ }
1796
+ `;
1797
+ }
1350
1798
  export function serverConfig(broker, spaces, opts) {
1351
1799
  if (!spaces.length)
1352
1800
  throw new Error("serverConfig: at least one space account is required");
@@ -1361,6 +1809,14 @@ export function serverConfig(broker, spaces, opts) {
1361
1809
  }
1362
1810
  const port = opts.port ?? 4222;
1363
1811
  const host = opts.host ?? "127.0.0.1";
1812
+ // The websocket listener (item 6): LOCALHOST by default, no_tls for the dev loopback. Emitted only
1813
+ // when wsPort is set — a broker with no console session client opens no ws surface.
1814
+ const websocket = opts.wsPort === undefined ? "" : `websocket {
1815
+ host: ${opts.wsHost ?? "127.0.0.1"}
1816
+ port: ${opts.wsPort}
1817
+ no_tls: true
1818
+ }
1819
+ `;
1364
1820
  // A minted "agent" carries its full permission allow-list inline in its user JWT, which the
1365
1821
  // client sends in the CONNECT protocol line. With per-channel + JetStream-API grants that JWT
1366
1822
  // exceeds the 4 KB default max_control_line at ~2 channels, and the server then silently drops
@@ -1368,12 +1824,13 @@ export function serverConfig(broker, spaces, opts) {
1368
1824
  // agent JWT — but right-sized, not generous: the CONNECT line is parsed BEFORE auth, so the cap
1369
1825
  // is a per-connection pre-auth allocation under connection flooding. 64 KB clears a many-channel
1370
1826
  // agent JWT (~4–8 KB) with wide margin while keeping the pre-auth surface ~16× tighter than 1 MB.
1827
+ const tlsBlock = renderTlsBlock(opts.transport);
1371
1828
  return `# Generated by \`cotal up\` - do not edit by hand.
1372
1829
  host: ${host}
1373
1830
  port: ${port}
1374
1831
  max_control_line: 65536
1375
- jetstream { store_dir: ${JSON.stringify(opts.storeDir)} }
1376
- operator: ${broker.operator.jwt}
1832
+ ${tlsBlock}jetstream { store_dir: ${JSON.stringify(opts.storeDir)} }
1833
+ ${websocket}operator: ${broker.operator.jwt}
1377
1834
  system_account: ${broker.sys.pub}
1378
1835
  resolver: MEMORY
1379
1836
  resolver_preload: {