@cotal-ai/core 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/acls.d.ts +21 -0
  2. package/dist/acls.d.ts.map +1 -1
  3. package/dist/acls.js +44 -1
  4. package/dist/acls.js.map +1 -1
  5. package/dist/artifact.d.ts +45 -0
  6. package/dist/artifact.d.ts.map +1 -0
  7. package/dist/artifact.js +82 -0
  8. package/dist/artifact.js.map +1 -0
  9. package/dist/backup-config.d.ts +9 -2
  10. package/dist/backup-config.d.ts.map +1 -1
  11. package/dist/backup-config.js +6 -2
  12. package/dist/backup-config.js.map +1 -1
  13. package/dist/broker-tls.d.ts +70 -0
  14. package/dist/broker-tls.d.ts.map +1 -0
  15. package/dist/broker-tls.js +195 -0
  16. package/dist/broker-tls.js.map +1 -0
  17. package/dist/canonical.d.ts +14 -1
  18. package/dist/canonical.d.ts.map +1 -1
  19. package/dist/canonical.js +21 -1
  20. package/dist/canonical.js.map +1 -1
  21. package/dist/channels.js +4 -4
  22. package/dist/channels.js.map +1 -1
  23. package/dist/endpoint-action.d.ts +108 -3
  24. package/dist/endpoint-action.d.ts.map +1 -1
  25. package/dist/endpoint-action.js +209 -20
  26. package/dist/endpoint-action.js.map +1 -1
  27. package/dist/endpoint-binding.d.ts +138 -8
  28. package/dist/endpoint-binding.d.ts.map +1 -1
  29. package/dist/endpoint-binding.js +233 -26
  30. package/dist/endpoint-binding.js.map +1 -1
  31. package/dist/endpoint-contract-store.d.ts +24 -1
  32. package/dist/endpoint-contract-store.d.ts.map +1 -1
  33. package/dist/endpoint-contract-store.js +79 -27
  34. package/dist/endpoint-contract-store.js.map +1 -1
  35. package/dist/endpoint-envelope.d.ts +15 -37
  36. package/dist/endpoint-envelope.d.ts.map +1 -1
  37. package/dist/endpoint-envelope.js +93 -44
  38. package/dist/endpoint-envelope.js.map +1 -1
  39. package/dist/endpoint-error.d.ts +33 -0
  40. package/dist/endpoint-error.d.ts.map +1 -0
  41. package/dist/endpoint-error.js +34 -0
  42. package/dist/endpoint-error.js.map +1 -0
  43. package/dist/endpoint-grants.d.ts +48 -7
  44. package/dist/endpoint-grants.d.ts.map +1 -1
  45. package/dist/endpoint-grants.js +97 -9
  46. package/dist/endpoint-grants.js.map +1 -1
  47. package/dist/endpoint-invoke.d.ts +138 -0
  48. package/dist/endpoint-invoke.d.ts.map +1 -0
  49. package/dist/endpoint-invoke.js +340 -0
  50. package/dist/endpoint-invoke.js.map +1 -0
  51. package/dist/endpoint-records.d.ts.map +1 -1
  52. package/dist/endpoint-records.js +17 -0
  53. package/dist/endpoint-records.js.map +1 -1
  54. package/dist/endpoint-serve-kv.d.ts +97 -0
  55. package/dist/endpoint-serve-kv.d.ts.map +1 -0
  56. package/dist/endpoint-serve-kv.js +336 -0
  57. package/dist/endpoint-serve-kv.js.map +1 -0
  58. package/dist/endpoint-serve.d.ts.map +1 -1
  59. package/dist/endpoint-serve.js +21 -1
  60. package/dist/endpoint-serve.js.map +1 -1
  61. package/dist/endpoint-service.d.ts +1 -1
  62. package/dist/endpoint-service.d.ts.map +1 -1
  63. package/dist/endpoint-service.js +34 -10
  64. package/dist/endpoint-service.js.map +1 -1
  65. package/dist/endpoint-session-rail.d.ts +127 -0
  66. package/dist/endpoint-session-rail.d.ts.map +1 -0
  67. package/dist/endpoint-session-rail.js +325 -0
  68. package/dist/endpoint-session-rail.js.map +1 -0
  69. package/dist/endpoint-session.d.ts +1 -115
  70. package/dist/endpoint-session.d.ts.map +1 -1
  71. package/dist/endpoint-session.js +4 -316
  72. package/dist/endpoint-session.js.map +1 -1
  73. package/dist/endpoint-subjects.d.ts +10 -1
  74. package/dist/endpoint-subjects.d.ts.map +1 -1
  75. package/dist/endpoint-subjects.js +10 -1
  76. package/dist/endpoint-subjects.js.map +1 -1
  77. package/dist/endpoint-verbs.d.ts +1 -2
  78. package/dist/endpoint-verbs.d.ts.map +1 -1
  79. package/dist/endpoint-verbs.js +6 -6
  80. package/dist/endpoint-verbs.js.map +1 -1
  81. package/dist/endpoint.d.ts +136 -11
  82. package/dist/endpoint.d.ts.map +1 -1
  83. package/dist/endpoint.js +329 -24
  84. package/dist/endpoint.js.map +1 -1
  85. package/dist/identity.d.ts +19 -0
  86. package/dist/identity.d.ts.map +1 -1
  87. package/dist/identity.js +12 -0
  88. package/dist/identity.js.map +1 -1
  89. package/dist/index.d.ts +8 -0
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +8 -0
  92. package/dist/index.js.map +1 -1
  93. package/dist/kv-scan.d.ts +8 -0
  94. package/dist/kv-scan.d.ts.map +1 -1
  95. package/dist/kv-scan.js +8 -0
  96. package/dist/kv-scan.js.map +1 -1
  97. package/dist/lease.d.ts +12 -5
  98. package/dist/lease.d.ts.map +1 -1
  99. package/dist/lease.js.map +1 -1
  100. package/dist/lifecycle-saga.d.ts +174 -0
  101. package/dist/lifecycle-saga.d.ts.map +1 -0
  102. package/dist/lifecycle-saga.js +418 -0
  103. package/dist/lifecycle-saga.js.map +1 -0
  104. package/dist/lifecycle-state.d.ts +202 -0
  105. package/dist/lifecycle-state.d.ts.map +1 -0
  106. package/dist/lifecycle-state.js +347 -0
  107. package/dist/lifecycle-state.js.map +1 -0
  108. package/dist/parts.d.ts +13 -0
  109. package/dist/parts.d.ts.map +1 -0
  110. package/dist/parts.js +17 -0
  111. package/dist/parts.js.map +1 -0
  112. package/dist/provision.d.ts +106 -9
  113. package/dist/provision.d.ts.map +1 -1
  114. package/dist/provision.js +506 -110
  115. package/dist/provision.js.map +1 -1
  116. package/dist/schema-profile.d.ts +3 -2
  117. package/dist/schema-profile.d.ts.map +1 -1
  118. package/dist/schema-profile.js +242 -18
  119. package/dist/schema-profile.js.map +1 -1
  120. package/dist/session-browser.d.ts +15 -0
  121. package/dist/session-browser.d.ts.map +1 -0
  122. package/dist/session-browser.js +14 -0
  123. package/dist/session-browser.js.map +1 -0
  124. package/dist/session-terminal-frames.d.ts +36 -0
  125. package/dist/session-terminal-frames.d.ts.map +1 -0
  126. package/dist/session-terminal-frames.js +149 -0
  127. package/dist/session-terminal-frames.js.map +1 -0
  128. package/dist/spaces.d.ts.map +1 -1
  129. package/dist/spaces.js +6 -1
  130. package/dist/spaces.js.map +1 -1
  131. package/dist/streams.d.ts +53 -1
  132. package/dist/streams.d.ts.map +1 -1
  133. package/dist/streams.js +135 -8
  134. package/dist/streams.js.map +1 -1
  135. package/dist/subjects.d.ts +32 -26
  136. package/dist/subjects.d.ts.map +1 -1
  137. package/dist/subjects.js +38 -25
  138. package/dist/subjects.js.map +1 -1
  139. package/dist/types.d.ts +27 -1
  140. package/dist/types.d.ts.map +1 -1
  141. package/package.json +6 -1
package/dist/provision.js CHANGED
@@ -17,10 +17,13 @@
17
17
  import { join } from "node:path";
18
18
  import { decode, 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, 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";
20
+ import { token, spacePrefix, chatSubject, assertValidChannel, channelInAllow, principalKey, parsePrincipalKey, deprovisionTargetPrincipal, principalTags, assertInboxConnId, DEV_OWNER, unicastSubject, anycastSubject, controlServiceSubject, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, CONTROL_AUTH_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";
21
+ import { epCallerGrantRows, epServeGrantRows, epBaselineGrantRows, spawnCallerCapabilities, operatorInstrumentCapabilities, epDescribeAllGrantRow, BASELINE_LIFECYCLE_ENDPOINT, } from "./endpoint-grants.js";
22
22
  import { assertServeGrantMintable, finalizeServeIssuance } from "./endpoint-service.js";
23
- import { effectsBindGrants, poolOwnerBindGrants } from "./endpoint-binding.js";
23
+ import { effectsBindGrants, poolOwnerBindGrants, goalWriterGrants, sessionLedgerGrants, epAuthBucket, sessionsBucket, epcStreamName, epjStreamName, epfStreamName, epeStreamName, eptReqStreamName, eprStreamName, eptStreamName, epwStreamName } from "./endpoint-binding.js";
24
+ import { epsSubject } from "./endpoint-subjects.js";
25
+ import { recordsBucket, recordSpecKey, recordStatusKey, recordAtomicKey, RECORD_KINDS, GOVERN_HEAD } from "./endpoint-records.js";
26
+ import { lifecycleHeadKey, uidReservationKey, issuanceGateKey, staticSlotKey, STATIC_SLOT_PREFIX, epgateKey, epcredFamilyPrefix } from "./lifecycle-state.js";
24
27
  import { rawDigest } from "./canonical.js";
25
28
  import { credsClaims } from "./identity.js";
26
29
  import { backupProfilePermissions, restoreProfilePermissions, } from "./backup.js";
@@ -49,6 +52,8 @@ export const CREDENTIAL_LIFETIMES = {
49
52
  provisioner: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "setup/spawn provisioning window only" },
50
53
  deprovisioner: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "target-pinned teardown window only" },
51
54
  "retirement-requester": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "one despawn's retirement request window; request+reply only" },
55
+ "lifecycle-executor": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "one static lifecycle operation's 13.1 state-write window (activation / terminal / renewal ledger append)" },
56
+ "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
57
  operator: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "send/dm/join/probe-style operator command" },
53
58
  purger: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "history purge command" },
54
59
  backup: { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "offline snapshot phase; exact stream and delivery subject, memory-only" },
@@ -61,6 +66,11 @@ export const CREDENTIAL_LIFETIMES = {
61
66
  "control-caller-admin": { class: "one-shot", defaultTtlSeconds: FIVE_MINUTES, note: "stop/attach admin control call" },
62
67
  deployer: { class: "one-shot", note: "manifest deploy spans planning/launch/ledger; needs near-expiry guard or remint before default exp" },
63
68
  "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" },
69
+ "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)" },
70
+ "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" },
71
+ "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" },
72
+ "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)" },
73
+ "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
74
  "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
75
  "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
76
  };
@@ -322,8 +332,13 @@ export async function provisionAgentDurables(provisioner, pr, opts = {}) {
322
332
  // live-only launcher, e.g. `cotal spawn --live-only`) opts out of the ACL row → the daemon never
323
333
  // authorizes a durable backstop for it, so it stays live-only.
324
334
  // 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);
335
+ // reissueAcl — rides THIS mint: allowSubscribe was just baked into the user JWT above. Separate
336
+ // from commitAcl so an ordinary registry writer cannot raise the ceiling by passing a flag
337
+ // (SPEC §9.6). Not crypto-bound to the JWT bytes — process discipline that this call stays next
338
+ // to the mint (ACL-authority panel residual).
339
+ if (opts.durableMembership !== false) {
340
+ await provisioner.reissueAcl(principalKey(pr.owner, pr.actor).key, uid, allowSubscribe);
341
+ }
327
342
  if (opts.role)
328
343
  await provisioner.provisionTaskQueue(opts.role);
329
344
  return allowSubscribe;
@@ -434,6 +449,31 @@ export function permissionsFor(profile, space, pr, opts) {
434
449
  const req = controlServiceSubject(space, CONTROL_AUTH_ADMIN, opts.retirementRequester.owner, opts.retirementRequester.actor);
435
450
  return { pub: { allow: [req] }, sub: { allow: [`${req}.reply.>`, `_INBOX_${pr.connId}.>`] } };
436
451
  }
452
+ if (profile === "endpoint-evictor") {
453
+ // P2 item 3 (slice 3a): a SCOPED delivery-admin caller for ONE re-registration's verify-evict.
454
+ // Publish EXACTLY this credential's OWN delivery-admin control subject + $JS.API.INFO; subscribe
455
+ // its own reply subtree + inbox. NO lease, presence, store read, consumer, KV, or executing
456
+ // right — the daemon does the $SYS scan/KICK (subject-gated authority, like the supervisor evictor
457
+ // it narrows) and the manager passes only the predecessor's principal. Narrower than the
458
+ // `supervisor` profile the auth barrier-evict reuses (its #30 residual, done here for the manager).
459
+ const req = controlServiceSubject(space, CONTROL_DELIVERY_ADMIN, pr.owner, pr.actor);
460
+ return { pub: { allow: [req, "$JS.API.INFO"] }, sub: { allow: [`${req}.reply.>`, `_INBOX_${pr.connId}.>`] } };
461
+ }
462
+ if (profile === "lifecycle-executor") {
463
+ if (!opts.lifecycleExecutor)
464
+ throw new Error("permissionsFor: lifecycle-executor requires opts.lifecycleExecutor ({owner, actor, lifecycleUid, alias} of the ONE incarnation it may move)");
465
+ return lifecycleExecutorPermissions(space, pr, opts.lifecycleExecutor);
466
+ }
467
+ if (profile === "endpoint-serve-executor") {
468
+ if (!opts.endpointServeExecutor)
469
+ throw new Error("permissionsFor: endpoint-serve-executor requires opts.endpointServeExecutor ({endpoint, instanceId} of the ONE endpoint instance it may register/serve-mint)");
470
+ return endpointServeExecutorPermissions(space, pr, opts.endpointServeExecutor);
471
+ }
472
+ if (profile === "goal-writer") {
473
+ if (!opts.goalWriter)
474
+ throw new Error("permissionsFor: goal-writer requires opts.goalWriter ({endpoint} whose action goals this connection binds + commits)");
475
+ return goalWriterPermissions(space, pr, opts.goalWriter);
476
+ }
437
477
  if (profile === "purger")
438
478
  return purgerPermissions(space, pr); // ephemeral history-purge (closure (ii))
439
479
  if (profile === "backup") {
@@ -457,11 +497,17 @@ export function permissionsFor(profile, space, pr, opts) {
457
497
  if (profile === "teardown")
458
498
  return teardownPermissions(space, pr); // sole STREAM.DELETE holder (PR 1.5)
459
499
  if (profile === "control-caller-privileged")
460
- return controlCallerPermissions(space, pr, CONTROL_PRIVILEGED); // ps/start (PR 1.5)
500
+ return controlCallerPermissions(space, pr, "privileged", opts); // ps/start reads (PR 1.5)
461
501
  if (profile === "control-caller-admin")
462
- return controlCallerPermissions(space, pr, CONTROL_ADMIN); // stop/attach (PR 1.5)
502
+ return controlCallerPermissions(space, pr, "admin", opts); // any-mode stop/attach (PR 1.5)
463
503
  if (profile === "deployer")
464
- return deployerPermissions(space, pr, opts.controlTier ?? CONTROL_ADMIN); // spawn -f deploy authority (PR 1.5; user-mode view rides privileged)
504
+ return deployerPermissions(space, pr, opts.controlTier ?? "admin", opts); // spawn -f deploy authority (PR 1.5; user-mode view rides privileged)
505
+ if (profile === "session-caller")
506
+ return sessionCallerPermissions(space, pr, opts.sessionCaller); // one §13.6 session's caller rails (P2 item 6)
507
+ if (profile === "session-serving")
508
+ return sessionServingPermissions(space, pr, opts.sessionServing); // one §13.6 session's SERVING rails (P2 item 6)
509
+ if (profile === "session-ledger")
510
+ return sessionLedgerPermissions(space, pr); // the dedicated session ledger, no rails (P2 item 6)
465
511
  if (profile === "endpoint-serve")
466
512
  // Serve rows are emitted ONLY by mintCreds behind the §13.1 issuance fence — never via this
467
513
  // exported builder, so a direct signer/callout can't obtain unfenced serve rows (SPEC 13.1/13.9).
@@ -542,7 +588,6 @@ export function permissionsFor(profile, space, pr, opts) {
542
588
  // channel must equal its wire token, or the minted grant would alias the logical ACL.
543
589
  for (const ch of [...allowSubscribe, ...allowPublish])
544
590
  assertValidChannel(ch);
545
- const manager = opts.manager ?? CONTROL_PRIVILEGED;
546
591
  if (!pr.lifecycleUid)
547
592
  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
593
  const uid = assertLifecycleToken(pr.lifecycleUid);
@@ -555,7 +600,8 @@ export function permissionsFor(profile, space, pr, opts) {
555
600
  ...allowPublish.map((ch) => chatSubject(space, pr.owner, pr.actor, ch)),
556
601
  unicastSubject(space, "*", "*", pr.owner, pr.actor), // inst.*.*.<o>.<a> — DM any instance, as me
557
602
  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
603
+ // Self stop/despawn rides the v0.4 ep baseline (`stop` self-mode)the manager `ctl` rail is
604
+ // deleted (1d). The delivery-daemon rail below is a separate service (Plane-3), kept.
559
605
  // ctl.delivery.<o>.<a> — request a durable backstop join/leave/list from the SERVER-SIDE delivery
560
606
  // daemon (NOT the manager). The reply rides this same subtree (`ctl.delivery.<o>.<a>.reply.<n>`, in
561
607
  // sub.allow below) so the daemon can answer without broad inbox-publish — see CONTROL_DELIVERY.
@@ -620,20 +666,9 @@ export function permissionsFor(profile, space, pr, opts) {
620
666
  // allowed — the privileged provisioner pre-creates svc_<role> filtered to svc.<role>.*.
621
667
  pubAllow.push(`$JS.API.CONSUMER.INFO.${TASK}.${svcD}`, `$JS.API.CONSUMER.MSG.NEXT.${TASK}.${svcD}`, `$JS.ACK.${TASK}.${svcD}.>`);
622
668
  }
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
- }
669
+ // Spawn / admin capability control reach rides the v0.4 ep rows below (the manager `ctl` rail is
670
+ // deleted, 1d) the spawn set (owner-mode manager lifecycle) and the admin instrument set
671
+ // (any-mode + manager.admin family) are added to the ep caller rows, not a ctl subject.
637
672
  // v0.4 endpoint rails (SPEC §13.9 caller rows). EVERY agent gets the Appendix-B BASELINE set
638
673
  // (wildcard describe + delivery join/leave/list + self-mode lifecycle + the reply rail), keyed
639
674
  // on the SAME lifecycle uid as the agent's durables; the spawn capability adds the owner-mode
@@ -651,6 +686,13 @@ export function permissionsFor(profile, space, pr, opts) {
651
686
  const epSub = [...baseline.sub];
652
687
  if (opts.capabilities?.includes("spawn"))
653
688
  pubAllow.push(...epCallerGrantRows(space, spawnCallerCapabilities(pr.owner), epCaller).pub);
689
+ if (opts.capabilities?.includes("admin"))
690
+ // The admin capability's ep mirror (the 1c grant-migration table): the v0.3 `ctl.<admin>`
691
+ // subject above grants the FULL admin-tier op reach, so its holder gets the admin instrument
692
+ // set on the ep rails — any-mode despawn/attach + the `manager.admin` family + the reads. In
693
+ // user mode this is the ledger `admin` scope arriving via the callout, the broker-enforced
694
+ // half of the tier; the manager's ledger-derived per-op admin flag stays on top of it.
695
+ pubAllow.push(...epCallerGrantRows(space, operatorInstrumentCapabilities("admin"), epCaller).pub);
654
696
  if (opts.endpointCapabilities?.length) {
655
697
  if (opts.lifecycleUid !== undefined && assertLifecycleToken(opts.lifecycleUid) !== uid)
656
698
  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 +729,9 @@ export function permissionsFor(profile, space, pr, opts) {
687
729
  // Replies to this agent's durable join/leave/list requests ride `ctl.delivery.<o>.<a>.>` (NOT the
688
730
  // per-id _INBOX), so the scoped delivery daemon can answer without broad inbox-publish.
689
731
  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] } };
732
+ // Manager control replies ride the v0.4 ep reply rail (in `epSub`, keyed on the caller triple)
733
+ // the `ctl.<tier>.<id>.reply.>` subtrees are gone with the ctl rail (1d).
734
+ return { pub: { allow: pubAllow, deny: pubDeny }, sub: { allow: [inbox, deliveryReplies, ...subChat, ...epSub] } };
701
735
  }
702
736
  /** The long-lived SUPERVISOR permission set (closure (ii), residual 2) — the always-on manager daemon
703
737
  * (`manager.ts` `this.ep`), carved down from the former allow-all `manager`. THIS is the cred whose
@@ -710,25 +744,27 @@ export function permissionsFor(profile, space, pr, opts) {
710
744
  * `setActivity`, a presence write), NO DM/DLV read of any kind (no consumer-create, no native sub), NO
711
745
  * stream CREATE/DELETE/PURGE/UPDATE, NO channel-registry access (the daemon sets `watchChannels:false`).
712
746
  * `$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
747
+ * leaked supervisor cred can hold the lease and read the public roster; it cannot read a DM, forge an
714
748
  * actor, provision, purge, or tamper with a stream. */
715
749
  function supervisorPermissions(space, pr) {
716
750
  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.>`);
751
+ // 1d: the supervisor no longer serves the manager control tiers the manager's control surface
752
+ // is its v0.4 `service` endpoint, served on a SEPARATE connection under its own `endpoint-serve`
753
+ // credential (its rails are that credential's grant, not the supervisor's). The supervisor now
754
+ // holds only the lease, presence, and the ONE delivery-admin call.
723
755
  return {
724
756
  pub: {
725
757
  allow: [
726
758
  "$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.
759
+ // Per-instance manager liveness lease (managerBucket, pre-created at `cotal up`): OPEN-ONLY bind +
760
+ // CAS this instance's own `lease.<instanceId>` key (acquire/renew/release) + read the subtree. P2
761
+ // item 3 demoted the per-space singleton to per-instance keys, so the write grant spans `lease.*`
762
+ // (every instance of this space shares this one supervisor principal — the isolation between
763
+ // instances is the logical-id/CAS boundary, not a cred boundary). NO STREAM.CREATE (pre-created),
764
+ // DELETE, or PURGE.
729
765
  `$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)
766
+ `$JS.API.STREAM.MSG.GET.${MKV}`, // readManagerLease (last_by_subj lease.*) + CAS-conflict kv.get
767
+ `$KV.${managerBucket(space)}.${MANAGER_LEASE_KEY}.*`, // this instance's lease.<id> key (create/update/delete = $KV publishes)
732
768
  // Presence: publish OWN key + watch the roster. Own key only (no peer-key forge — residual 3); no
733
769
  // presence-stream purge/delete (no force-offline tamper). No presence kv.get (roster is the in-memory
734
770
  // watch cache + sweep), so no STREAM.MSG.GET on presence.
@@ -737,9 +773,6 @@ function supervisorPermissions(space, pr) {
737
773
  `$JS.API.CONSUMER.CREATE.${PKV}.>`, // kv.watch ordered consumer (roster)
738
774
  `$JS.API.CONSUMER.INFO.${PKV}.>`,
739
775
  "$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
776
  // The ONE control service the supervisor CALLS (D5 slice 5): the delivery daemon's privileged
744
777
  // admin rail — the manager is the class-2 renewal owner, and after re-signing the daemon creds
745
778
  // files it requests `reloadCreds` here so adoption is an explicit, auditable event. Self-scoped
@@ -748,10 +781,10 @@ function supervisorPermissions(space, pr) {
748
781
  ],
749
782
  },
750
783
  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)}.>`],
784
+ // Own reply inbox + the delivery-admin reply subtree for its OWN requests. NO chat/inst/dlv
785
+ // native sub (the supervisor reads no feed), NO manager control-tier serve (1d: that moved to
786
+ // the endpoint-serve credential), NO broad `$JS.>`/`$KV.>` (the residual-2 read/admin path is gone).
787
+ allow: [`_INBOX_${pr.connId}.>`, `${controlServiceSubject(space, CONTROL_DELIVERY_ADMIN, pr.owner, pr.actor)}.>`],
755
788
  },
756
789
  };
757
790
  }
@@ -852,21 +885,29 @@ function channelPurgerPermissions(space, pr) {
852
885
  }
853
886
  /** TEARDOWN (PR 1.5) — `cotal down -f` space teardown. The SOLE cred that keeps `STREAM.DELETE` (the
854
887
  * 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).
888
+ * → invoke the manager's `ps` + any-mode `despawn` over the ep rails to politely stop the managed
889
+ * agents → `deleteChannels`
890
+ * (channel-registry key delete + CHAT purge) → `deleteSpace` (STREAM.DELETE all 13 space streams/buckets).
857
891
  * So it reads state, CALLS admin control, deletes channels, and deletes streams — but NEVER reads a
858
892
  * DM/DLV body, posts chat, or forges. Isolated here so no standing operator/provisioner/supervisor cred
859
893
  * can delete a stream; a leaked teardown can wipe a space you own + stop its agents (that IS its job),
860
894
  * nothing else. Minted ephemerally per teardown from the local trust material (same-checkout `down -f`). */
861
895
  function teardownPermissions(space, pr) {
896
+ // The ep-rail mirror of the admin deploy tier (1c.2c): teardown reads `ps` and stops owned agents
897
+ // it did not spawn (any-mode despawn) - the admin instrument set. Lifecycle-keyed, so a uid is
898
+ // required at mint (fail-loud).
899
+ const ep = instrumentEpRows(space, pr, "admin");
862
900
  const CHAT = chatStream(space);
863
901
  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).
902
+ // deleteSpace() deletes EVERY stream + KV bucket setup creates (5 streams + 7 KV buckets + the
903
+ // artifact object store = 13); each needs INFO (jsm existence) + DELETE. This is the ONLY cred that
904
+ // holds STREAM.DELETE (face-b isolated here). This list and deleteSpace()'s own array must agree:
905
+ // a stream in one and not the other is either an undeletable leak or a grant for nothing.
866
906
  const del = [
867
907
  CHAT, dmStream(space), taskStream(space), inboxStream(space), dlvStream(space),
868
908
  PKV, CHKV, `KV_${membersBucket(space)}`, `KV_${aclBucket(space)}`,
869
909
  `KV_${membershipBucket(space)}`, `KV_${deliveryBucket(space)}`, `KV_${managerBucket(space)}`,
910
+ objectStoreStream(artifactBucket(space)),
870
911
  ].flatMap((s) => [`$JS.API.STREAM.INFO.${s}`, `$JS.API.STREAM.DELETE.${s}`]);
871
912
  return {
872
913
  pub: {
@@ -880,44 +921,106 @@ function teardownPermissions(space, pr) {
880
921
  `$JS.API.CONSUMER.CREATE.${CHKV}.>`,
881
922
  `$JS.API.CONSUMER.INFO.${CHKV}.>`,
882
923
  "$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),
924
+ // Stop the managed agents over the v0.4 ep rails only (ps + any-mode despawn) — the admin
925
+ // instrument set. The manager `ctl` rail is deleted (1d).
926
+ ...ep.pub,
885
927
  ...del,
886
928
  // deleteChannels/clearChannel: purge the channel's chat messages + delete its registry key.
887
929
  `$JS.API.STREAM.PURGE.${CHAT}`,
888
930
  `$KV.${channelBucket(space)}.>`,
889
931
  ],
890
932
  },
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.>`] },
933
+ // Own inbox (connectProbe presence-watch delivery + JS API responses) + the ep reply rail (the
934
+ // ps + any-mode despawn calls reply there). The `ctl.admin.<id>.reply.>` subtree is gone (1d).
935
+ sub: { allow: [`_INBOX_${pr.connId}.>`, ...ep.sub] },
896
936
  };
897
937
  }
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.
938
+ /** CONTROL-CALLER (PR 1.5; ep-only since 1d) — the operator's lifecycle commands
939
+ * (`cotal ps/start/stop/attach`, `manager/commands.ts`). It invokes the manager's v0.4 service
940
+ * endpoint and reads the bounded reply on the ep reply rail. That is ALL — no `$JS`, no `$KV`,
941
+ * no chat/DM: it forges nothing, reads no body.
901
942
  *
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);
943
+ * The tiers stay SPLIT because the BROKER grant is load-bearing (the 1c decision): an any-mode
944
+ * despawn/attach row *is* cross-agent reachthe manager maps mode `any` to its admin
945
+ * authorization path, so which ROWS an instrument holds is the tier boundary. Therefore:
946
+ * `control-caller-privileged` (ps/start) holds the manager reads + untargeted `spawn` +
947
+ * `define-persona`structurally barred from cross-agent ops (no any-mode row). This is the
948
+ * high-frequency path; it never needs admin reach.
949
+ * • `control-caller-admin` (stop/attach) adds the any-mode despawn/attach rows + the
950
+ * `manager.admin` family — it genuinely needs cross-agent reach. Its containment is the
951
+ * broker gating the any-mode rows + the cred being ephemeral (mint one request
952
+ * disconnect, from the local signing seed); on a user mesh the manager's serve-time ledger
953
+ * re-check sits on top. */
954
+ function controlCallerPermissions(space, pr, epTier, opts = {}) {
955
+ // 1d: the manager `ctl` rail is gone — an operator instrument holds ONLY its v0.4 ep rows (the
956
+ // tier-matched request set, the reply rail, describe, the one epc fetch). The `epTier` selects
957
+ // privileged (ps/start reads) vs admin (any-mode stop/attach) exactly as the ctl tier did.
958
+ //
959
+ // B6 / `--on <instanceId>`: these instruments are ONE-SHOT, minted per control call, and the
960
+ // resolve that pins the instance happens BEFORE the mint. So the caller can hand the exact
961
+ // instance id down and get the exact `ep.inst.<endpoint>.<iid>.<command>` row for THIS invocation
962
+ // and nothing else — the least-privilege issuance, with no standing wildcard anywhere. The
963
+ // `extra` seam is the same one the deployer's owner-equality `launch` row already rides, and the
964
+ // emitter's `if (cap.instanceId)` branch validates the token, so a malformed id fails loud at
965
+ // mint rather than widening a subject.
966
+ const ep = instrumentEpRows(space, pr, epTier, opts.endpointCapabilities ?? []);
967
+ return {
968
+ // The PRIVILEGED tier (the `cotal ps` instrument) also carries the SCOPED §13.9 records read the
969
+ // class scatter's freeze rides (P2 item 3): `freezeExpectedSet` enumerates `svc.<endpoint>.*.spec`
970
+ // and LEADER-reads each frozen slot's svc spec/status before it scatters `ps` on the `all` rail.
971
+ // The admin tier (stop/attach) never scatters, so it gets no records read.
972
+ pub: { allow: epTier === "privileged" ? [...ep.pub, ...scatterFreezeReadRows(space)] : ep.pub },
973
+ sub: { allow: [`_INBOX_${pr.connId}.>`, ...ep.sub] },
974
+ };
975
+ }
976
+ /** The SCOPED §13.9 records-read rows the class-scatter freeze rides (P2 item 3, `cotal ps`): the
977
+ * `svc.*` enumeration consumer + the leader-served per-slot spec/status read of the endpoint's
978
+ * `svc` registry — a READ of exactly the service-registration keys, no write, no other bucket. A
979
+ * new D32 matrix row. The keyed Direct Get is subject-PINNED to `svc.>`; the enumeration consumer
980
+ * and the leader `STREAM.MSG.GET` are STREAM-scoped because the requested key rides the PAYLOAD
981
+ * (which a subject grant cannot narrow) — the SAME NAMED RESIDUAL every records reader already
982
+ * accepts (the provisioner, the lifecycle/serve executors): for this EPHEMERAL one-shot instrument's
983
+ * lifetime it can READ (never write) any records-store row — registration metadata, no secrets. */
984
+ function scatterFreezeReadRows(space) {
985
+ const REC = recordsBucket(space);
986
+ return [
987
+ // The `svc.<e>.*.spec` enumeration is a `STREAM.INFO` carrying a `subjects_filter` — ONE
988
+ // read-only metadata verb. It replaced a `kv.keys()` that rode an ordered ephemeral consumer and
989
+ // therefore needed CONSUMER.CREATE/INFO/DELETE on this bucket: three consumer-lifecycle verbs to
990
+ // list keys, on a credential that only ever wanted to read them.
991
+ //
992
+ // MEASURED, not assumed: with the enumeration converted and this row absent, the static/operator
993
+ // `cotal ps` is refused on `$JS.API.STREAM.INFO.KV_<records>` — a path that works today. The
994
+ // conversion and this row land TOGETHER or the operator path regresses.
995
+ `$JS.API.STREAM.INFO.KV_${REC}`,
996
+ // The per-slot spec/status reads (`freezeExpectedSet` + the registration reconcile) are
997
+ // leader-served `STREAM.MSG.GET` — stream-scoped (NAMED RESIDUAL above), plus the subject-pinned
998
+ // keyed Direct Get form for any direct-aware read path (scoped to the `svc.` registry prefix).
999
+ `$JS.API.STREAM.MSG.GET.KV_${REC}`,
1000
+ `$JS.API.DIRECT.GET.KV_${REC}.$KV.${REC}.svc.>`,
1001
+ ];
1002
+ }
1003
+ /** The v0.4 ep-rail rows of an operator INSTRUMENT credential (the 1c grant-migration table's
1004
+ * admin row): the tier-matched {@link operatorInstrumentCapabilities} request rows + the caller's
1005
+ * reply rail, the wildcard `describe` form, and the ONE subject-scoped §13.7 contract-store fetch
1006
+ * row (the same shape the agent baseline holds; the D32 audit's single exemption). These mirror
1007
+ * the instrument's ctl tier onto the ep rails during dual-serve; at 1d the ctl row disappears and
1008
+ * these ARE the instrument. The caller triple pins the instrument's own mint-time lifecycle uid
1009
+ * ({@link MintPrincipal.lifecycleUid}) — REQUIRED here: without it the reply rail cannot be pinned
1010
+ * and the mint fails loud rather than emit a triple-less (unfenced) caller surface. `extra` lets a
1011
+ * profile append its tier-refined additions (the user-mode deployer's owner-equality `launch`). */
1012
+ function instrumentEpRows(space, pr, tier, extra = []) {
1013
+ if (!pr.lifecycleUid)
1014
+ 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`);
1015
+ const epCaller = { owner: pr.owner, actor: pr.actor, uid: pr.lifecycleUid };
1016
+ const rows = epCallerGrantRows(space, [...operatorInstrumentCapabilities(tier), ...extra], epCaller);
914
1017
  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.>`] },
1018
+ pub: [
1019
+ epDescribeAllGrantRow(space, epCaller),
1020
+ `$JS.API.DIRECT.GET.${epcStreamName(space)}.${spacePrefix(space)}.epc.>`,
1021
+ ...rows.pub,
1022
+ ],
1023
+ sub: rows.sub,
921
1024
  };
922
1025
  }
923
1026
  /** ENDPOINT-SERVE (v0.4, SPEC §13.9 "Serve grants") — the per-instance serve credential:
@@ -970,23 +1073,34 @@ function endpointServePermissions(space, pr, opts) {
970
1073
  }
971
1074
  /** DEPLOYER (PR 1.5) — the `cotal spawn -f` manifest-deploy authority. `spawn -f` drives ONE
972
1075
  * `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,
1076
+ * membership feed, manager-singleton lease) AND invokes the running manager's `launch` + `ps`
1077
+ * readiness over the v0.4 ep rails. Those interleave on one connection, so a strict 3-connection
1078
+ * split would only refactor `live.ts` for marginal gain; `deployer` is that one coherent,
976
1079
  * ephemeral deploy cred. It is the SOLE profile that combines reads + admin-control — NOT a template a
977
1080
  * 4th command should reach for (revisit the connection split before adding a second such caller).
978
1081
  *
979
1082
  * Boundaries (all enforced by omission / default-deny): NO self-post (`chat`/`inst`/`svc`), NO `$JS.>`,
980
1083
  * 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) {
1084
+ * writes (channel seeding rides a SEPARATE `channel-writer` cred). It holds the admin INSTRUMENT ep
1085
+ * set (any-mode despawn/attach + the manager.admin family + reads) because manifest launch/ps
1086
+ * genuinely need cross-agent reach — and that IS real power: the manager's any-mode authz is
1087
+ * subject-gated (the any-mode ep row is mintable only under operator policy), so holding it lets it
1088
+ * stop/attach/launch ANY agent. The BROKER gating that row is the boundary. Containment is therefore
1089
+ * the LIFETIME, not a manager re-check: minted from LOCAL same-checkout auth for one `spawn -f`,
1090
+ * memory-only, dropped after deploy. If it is ever persisted, handed to user-supplied `--creds`, or
1091
+ * reused as a general "read + admin" cred, revisit. */
1092
+ function deployerPermissions(space, pr, epTier = "admin", opts = {}) {
1093
+ // The v0.4 ep rows of the deploy authority: static (admin) deploys carry the admin instrument
1094
+ // set; the user-mode deployer VIEW (privileged) carries the privileged set PLUS an untargeted
1095
+ // `launch` row — its launch stays owner-equality-authorized (the manager's ledger-derived admin
1096
+ // flag is false for a spawn-scoped deployer), exactly the v0.3 user-mode privileged-tier launch.
1097
+ // B6 / `--on`: the per-invocation pin APPENDS to this profile's standing set, never replaces it -
1098
+ // the privileged deployer view keeps its owner-equality `launch` row and additionally gets the one
1099
+ // exact `ep.inst.<endpoint>.<iid>.<command>` row for the instance this deploy resolved.
1100
+ const pinned = opts.endpointCapabilities ?? [];
1101
+ const ep = epTier === "admin"
1102
+ ? instrumentEpRows(space, pr, "admin", pinned)
1103
+ : instrumentEpRows(space, pr, "privileged", [{ endpoint: BASELINE_LIFECYCLE_ENDPOINT, command: "launch" }, ...pinned]);
990
1104
  const PKV = `KV_${presenceBucket(space)}`, CHKV = `KV_${channelBucket(space)}`;
991
1105
  const MSHIP = `KV_${membershipBucket(space)}`, MGRKV = `KV_${managerBucket(space)}`;
992
1106
  const DLVKV = `KV_${deliveryBucket(space)}`;
@@ -1017,16 +1131,15 @@ function deployerPermissions(space, pr, tier = CONTROL_ADMIN) {
1017
1131
  ...kvPointRead(MGRKV), // manager-singleton lease keyed read (waitManagerReady) — point-get, NO write, NO watch
1018
1132
  ...kvPointRead(DLVKV), // delivery-lease keyed read (preserve-state quiescence proof) — point-get, NO write, NO watch
1019
1133
  "$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),
1134
+ // 1d: launch + ps readiness ride the v0.4 ep rows only (the manager `ctl` rail is gone).
1135
+ // Static deploys carry the admin instrument set; the user-mode deployer VIEW carries the
1136
+ // privileged set + an owner-equality `launch` row (the manager's ledger-derived admin flag
1137
+ // is false for a spawn-scoped deployer, so its launch stays owner-equality-authorized).
1138
+ ...ep.pub,
1024
1139
  ],
1025
1140
  },
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.>`] },
1141
+ // Own inbox (presence/registry watch delivery + JS API responses) + the ep reply rail.
1142
+ sub: { allow: [`_INBOX_${pr.connId}.>`, ...ep.sub] },
1030
1143
  };
1031
1144
  }
1032
1145
  /** The ephemeral PURGER permission set (closure (ii), residual 2) — minted per-purge inside the daemon's
@@ -1077,12 +1190,35 @@ function provisionerPermissions(space, pr) {
1077
1190
  // Every backing stream the provisioner pre-creates — the 5 message streams + the KV buckets (a bucket's
1078
1191
  // backing stream is `KV_<bucket>`). `managerBucket` is now pre-created here too (so the supervisor binds
1079
1192
  // its lease open-only); members/membership/delivery are written by other creds but created at setup here.
1193
+ // The §13.12 AUTHORITY stores (records + auth + the P2 item 6 session ledger) join the list for the
1194
+ // STATIC manager's start-time `createEndpointStreams` (a superset of ensureAuthorityStores +
1195
+ // createSessionsStore): create-or-verify only — the provisioner holds NO value-write on any of them
1196
+ // (lifecycle state moves through the key-pinned `lifecycle-executor` cred; session rows through the
1197
+ // scoped `session-ledger` cred).
1080
1198
  const buckets = [
1081
1199
  presenceBucket, channelBucket, membersBucket, aclBucket, membershipBucket, deliveryBucket, managerBucket,
1200
+ recordsBucket, epAuthBucket, sessionsBucket,
1082
1201
  ].map((b) => `KV_${b(space)}`);
1083
1202
  // STREAM.CREATE + INFO for each (idempotent setup at `cotal up`; CREATE is create-if-matching, INFO covers
1084
1203
  // 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) => [
1204
+ // The §13.7 CONTRACT store (EPC) joins the list for the static manager's start-time
1205
+ // `ensureContractStore` (P2 item 1, 1c): create-or-verify only — the provisioner holds no
1206
+ // artifact-publish grant on it (publication rides the scoped endpoint-serve executor).
1207
+ // The seven §13.12 ENDPOINT streams join the list (P2 item 2): spawn-as-action makes the manager
1208
+ // the first EPF (goal facts) + EPE (progress) writer, and nothing provisioned the endpoint streams
1209
+ // before (no manager code wrote to them), so `createEndpointStreams` now runs at the manager's
1210
+ // start-time ensure over this provisioner. Create-or-verify only (idempotent, fail-loud on drift);
1211
+ // the provisioner holds no value-write on any of them (goal facts ride the scoped goal-writer cred).
1212
+ const endpointStreams = [epjStreamName, epfStreamName, epeStreamName, eptReqStreamName, eprStreamName, eptStreamName, epwStreamName].map((f) => f(space));
1213
+ // The artifact Object Store joins the list: `setupSpaceStreams` creates it, and under auth mode the
1214
+ // provisioner is the cred doing that creating. Its backing stream is `OBJ_<bucket>` - named
1215
+ // explicitly, because `$O.<bucket>.>` is outside the `cotal.<space>.>` grammar and no space-prefix
1216
+ // grant reaches it. CREATE + INFO only: the provisioner never publishes an object, never creates a
1217
+ // consumer on it, and never deletes it. That confinement is load-bearing rather than tidy - the
1218
+ // object-store client reads by creating an ephemeral PUSH consumer with a caller-chosen
1219
+ // `deliver_subject`, so a CONSUMER.CREATE here would be an exporter of every artifact in the space.
1220
+ const OBJ = objectStoreStream(artifactBucket(space));
1221
+ const streamSetup = [CHAT, DM, TASK, INBOX, DLV, epcStreamName(space), OBJ, ...endpointStreams, ...buckets].flatMap((s) => [
1086
1222
  `$JS.API.STREAM.CREATE.${s}`,
1087
1223
  `$JS.API.STREAM.INFO.${s}`,
1088
1224
  ]);
@@ -1114,6 +1250,22 @@ function provisionerPermissions(space, pr) {
1114
1250
  `$JS.API.DIRECT.GET.KV_${aclBucket(space)}.>`, // keyed get: `.>` (the key rides the subject)
1115
1251
  `$JS.API.STREAM.MSG.GET.KV_${channelBucket(space)}`,
1116
1252
  `$JS.API.DIRECT.GET.KV_${channelBucket(space)}.>`, // keyed get: `.>` (the key rides the subject)
1253
+ // The Unit B static-manager start path: `ensureAuthorityStores` UPDATEs the records store's
1254
+ // deny-flags exactly once at fresh creation (create → update → verify), and the boot
1255
+ // reconciliation sweep enumerates the manager's slot rows (`keys()` → an ordered consumer)
1256
+ // then reads each slot BODY (phase/uid/actor) to plan resume — the reads ride the
1257
+ // stream-scoped MSG.GET residual named below (records lifecycle metadata, no secrets).
1258
+ `$JS.API.STREAM.UPDATE.KV_${recordsBucket(space)}`,
1259
+ `$JS.API.CONSUMER.CREATE.KV_${recordsBucket(space)}.>`,
1260
+ `$JS.API.CONSUMER.INFO.KV_${recordsBucket(space)}.>`,
1261
+ `$JS.API.CONSUMER.DELETE.KV_${recordsBucket(space)}.>`,
1262
+ // ...and reads the slot-mapping rows so the sweep can plan resume actions. The KV client
1263
+ // reads a lazily-bound bucket via leader-served `STREAM.MSG.GET` — stream-scoped, NOT
1264
+ // key-scoped (the requested key rides the PAYLOAD). NAMED RESIDUAL: for its one-shot
1265
+ // lifetime the provisioner can READ (never write) any records-store row — lifecycle
1266
+ // metadata, no secrets. The keyed Direct Get grant stays for direct-aware read paths.
1267
+ `$JS.API.STREAM.MSG.GET.KV_${recordsBucket(space)}`,
1268
+ `$JS.API.DIRECT.GET.KV_${recordsBucket(space)}.$KV.${recordsBucket(space)}.${STATIC_SLOT_PREFIX}.>`,
1117
1269
  ],
1118
1270
  },
1119
1271
  // Replies only: every stream/consumer/KV-create PubAck and JS API response lands on the per-id inbox.
@@ -1121,6 +1273,185 @@ function provisionerPermissions(space, pr) {
1121
1273
  sub: { allow: [`_INBOX_${pr.connId}.>`] },
1122
1274
  };
1123
1275
  }
1276
+ /** The ephemeral, LIFECYCLE-PINNED §13.1 state-write permission set for the STATIC manager's
1277
+ * lifecycle executor (Unit B). One credential per lifecycle OPERATION (activation, terminal,
1278
+ * renewal ledger append): every grant names exactly ONE incarnation's keys — the alias head,
1279
+ * the uid reservation, the manager slot row, the issuance gate, and the `cred.<uid>.>` ledger
1280
+ * family — so a leaked executor cred can move one incarnation's state machine and nothing else.
1281
+ *
1282
+ * Reads: records reads ride the keyed Direct Get form (the key is ON the subject, so the read
1283
+ * grant stays key-pinned); the auth store is leader-served (`allow_direct=false`), so its reads
1284
+ * are body-selected `STREAM.MSG.GET` — stream-scoped, NOT key-scoped (the requested key rides
1285
+ * the PAYLOAD, which a subject grant cannot see). NAMED RESIDUAL: for its one-shot lifetime the
1286
+ * executor can READ (never write) other rows in the auth store. */
1287
+ function lifecycleExecutorPermissions(space, pr, pin) {
1288
+ const REC = recordsBucket(space), AUTH = epAuthBucket(space);
1289
+ // ALL keys DERIVED here from the pin coordinates — the slot key is `staticSlotKey(owner, alias)`,
1290
+ // NOT a caller-supplied literal, so a mis-constructed pin can only ever name ONE coherent
1291
+ // incarnation's rows (guard the core: the profile enforces the "one incarnation" promise, it
1292
+ // does not merely assert it). The builders throw on any non-KV-safe segment.
1293
+ const recordKeys = [lifecycleHeadKey(pin.owner, pin.actor), uidReservationKey(pin.lifecycleUid), staticSlotKey(pin.owner, pin.alias)];
1294
+ return {
1295
+ pub: {
1296
+ allow: [
1297
+ "$JS.API.INFO",
1298
+ // Records-store CAS writes — the value-publish carries the key on the subject, so each
1299
+ // grant names exactly one of this incarnation's keys.
1300
+ ...recordKeys.map((k) => `$KV.${REC}.${k}`),
1301
+ // Auth-store CAS writes — this incarnation's gate + its cred-ledger family (renewals
1302
+ // append rows here; the terminal's B1 revoke CASes them).
1303
+ `$KV.${AUTH}.${issuanceGateKey(pin.lifecycleUid)}`,
1304
+ `$KV.${AUTH}.cred.${pin.lifecycleUid}.>`,
1305
+ // Keyed Direct Get reads of the same records keys (key-pinned) for direct-aware read
1306
+ // paths, PLUS the leader-served `STREAM.MSG.GET` the lazily-bound KV client actually
1307
+ // uses — stream-scoped, NOT key-scoped (the requested key rides the PAYLOAD, which a
1308
+ // subject grant cannot see). NAMED RESIDUAL: for its one-shot lifetime the executor can
1309
+ // READ (never write) other rows in BOTH authority stores — lifecycle metadata, no
1310
+ // secrets; every WRITE stays key-pinned above.
1311
+ ...recordKeys.map((k) => `$JS.API.DIRECT.GET.KV_${REC}.$KV.${REC}.${k}`),
1312
+ `$JS.API.STREAM.MSG.GET.KV_${REC}`,
1313
+ `$JS.API.STREAM.MSG.GET.KV_${AUTH}`,
1314
+ ],
1315
+ },
1316
+ sub: { allow: [`_INBOX_${pr.connId}.>`] },
1317
+ };
1318
+ }
1319
+ /** The ephemeral, ENDPOINT-INSTANCE-PINNED endpoint-serve executor permission set (P2 item 1,
1320
+ * 1a-serve): the manager mints this per registration/serve-mint op and drives the endpoint
1321
+ * registration barrier's `epgate` CAS + the mint fence's `epcred` stage/revoke THROUGH it — never
1322
+ * its standing seed/supervisor connection (critic #1's manager-specific "no seed shortcut"). Every
1323
+ * WRITE is key-pinned to exactly ONE (endpoint, instanceId): the gate `epgate.<ep>.<iid>`, its
1324
+ * serving ledger family `epcred.<ep>.<iid>.>`, and the registration's two records keys (the
1325
+ * instance's `svc` spec + the endpoint's governance head — `registerServiceInstance` drives the
1326
+ * slot-take/promote over this same connection). A leaked/mis-constructed executor can move exactly
1327
+ * one endpoint instance's serve state and nothing else. The auth store is `allow_direct=false`, so
1328
+ * reads are leader-served `STREAM.MSG.GET` (stream-scoped, NOT key-scoped — the key rides the
1329
+ * payload); enumeration of the epcred family rides an ordered `keys()` consumer. NAMED RESIDUAL:
1330
+ * for its one-shot lifetime the executor can READ (never write) other auth rows — endpoint/
1331
+ * credential metadata, no bearer bytes; every WRITE stays key-pinned. */
1332
+ /** The SELF-MEDIATED goal-writer profile (P2 item 2, spawn-as-action): exactly
1333
+ * {@link goalWriterGrants} for ITS endpoint — the goal bind + terminal facts, the goal-record KV
1334
+ * writes, and the leader-served fencing reads — plus the connection-scoped reply inbox. Disjoint
1335
+ * from the endpoint's serve credential (Q2): a serve connection carries none of these rows, so it
1336
+ * is broker-denied every goal write. */
1337
+ function goalWriterPermissions(space, pr, pin) {
1338
+ const g = goalWriterGrants(space, pin.endpoint, pr.connId);
1339
+ return { pub: { allow: g.publish }, sub: { allow: g.subscribe } };
1340
+ }
1341
+ /** The console/CLI per-session CALLER rows (P2 item 6): RAILS-ONLY for ONE §13.6 session — pub the
1342
+ * session's epoch-pinned `in` rail, sub its `out` rail plus the caller's own reply inbox, and
1343
+ * NOTHING else. Deliberately NO KV, NO JetStream API, NO store: the caller drives the terminal over
1344
+ * the two core-only eps subjects and never reads the session ledger, so there is no subject-blind
1345
+ * store read to widen (SPEC §13.9). The endpoint+sessionId+epoch pin the EXACT pair, so a cred for
1346
+ * session A authorizes nothing of session B (no wildcard). `epsSubject` validates every token, so a
1347
+ * malformed coordinate refuses at the mint rather than emitting a broadened subject. */
1348
+ function sessionCallerPermissions(space, pr, pin) {
1349
+ if (!pin)
1350
+ throw new Error('permissionsFor: "session-caller" requires opts.sessionCaller ({endpoint, sessionId, epoch}) - the ONE session\'s rails this cred may use');
1351
+ return {
1352
+ pub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "in")] },
1353
+ sub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "out"), `_INBOX_${pr.connId}.>`] },
1354
+ };
1355
+ }
1356
+ /** The manager's per-session SERVING rows (P2 item 6): the EXACT mirror of
1357
+ * {@link sessionCallerPermissions} with the directions swapped — sub the ONE session's epoch-pinned
1358
+ * `in` rail (caller→serving), pub its `out` rail (serving→caller), plus the connection-scoped reply
1359
+ * inbox, and NOTHING else. Same asymmetry §13.6 states: "the caller publishes `in` and subscribes
1360
+ * `out`; the serving instance the reverse".
1361
+ *
1362
+ * Deliberately NO KV and NO JetStream API — not even the session ledger. The serving side drives
1363
+ * bytes; the ledger is the standing `session-ledger` credential's, on a different connection. That
1364
+ * separation is what lets this credential be one-shot and die with its session while the durable
1365
+ * revocation authority outlives it (§13.6).
1366
+ *
1367
+ * This REPLACES a standing writer that held `eps.<endpoint>.*.<epoch>.{in,out}` and so could read
1368
+ * and write every live session's bytes at its epoch. The pin makes a credential for session A
1369
+ * authorize nothing of session B, and `epsSubject` validates every token, so a malformed
1370
+ * coordinate refuses at the mint rather than emitting a broadened subject. */
1371
+ function sessionServingPermissions(space, pr, pin) {
1372
+ if (!pin)
1373
+ throw new Error('permissionsFor: "session-serving" requires opts.sessionServing ({endpoint, sessionId, epoch}) - the ONE session\'s rails this cred may serve');
1374
+ return {
1375
+ pub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "out")] },
1376
+ sub: { allow: [epsSubject(space, pin.endpoint, pin.sessionId, pin.epoch, "in"), `_INBOX_${pr.connId}.>`] },
1377
+ };
1378
+ }
1379
+ /** The manager's SESSION-LEDGER rows (P2 item 6): the DEDICATED sessions-bucket rows and nothing
1380
+ * else — no session rail of any shape. Needs no pin: the grant carries no endpoint, epoch, or
1381
+ * session component, because §13.6's durable revocation authority is per-space, not per-session
1382
+ * (it must still be able to resolve and revoke a row after the endpoint that served it is gone).
1383
+ * {@link sessionLedgerGrants} carries the row rationale, the §13.9 subject-blindness confinement,
1384
+ * and the named `session.*` breadth residual. */
1385
+ function sessionLedgerPermissions(space, pr) {
1386
+ const g = sessionLedgerGrants(space, pr.connId);
1387
+ return { pub: { allow: g.publish }, sub: { allow: g.subscribe } };
1388
+ }
1389
+ function endpointServeExecutorPermissions(space, pr, pin) {
1390
+ const AUTH = epAuthBucket(space), REC = recordsBucket(space);
1391
+ // DERIVED from (endpoint, instanceId) via the core builders — never a caller literal.
1392
+ const gateKey = epgateKey(pin.endpoint, pin.instanceId);
1393
+ const credPrefix = epcredFamilyPrefix(pin.endpoint, pin.instanceId);
1394
+ // The registration writes this ONE instance's spec key plus the endpoint's governance head
1395
+ // (`registerServiceInstance` PHASE 1/3b: the slot-take + promote ride the SAME executor), and this
1396
+ // instance's own svc STATUS key (P2 item 3: the manager writes its CONVERGED `ready` status so it
1397
+ // is a §13.5 scatter member — `freezeExpectedSet` requires a status caught up to the current
1398
+ // registration; the write is epoch-fenced by `writeServiceStatus`).
1399
+ const recordKeys = [
1400
+ recordSpecKey(RECORD_KINDS.svc, [pin.endpoint, assertLifecycleToken(pin.instanceId, "instanceId")]),
1401
+ recordStatusKey(RECORD_KINDS.svc, [pin.endpoint, assertLifecycleToken(pin.instanceId, "instanceId")]),
1402
+ recordAtomicKey(GOVERN_HEAD, [pin.endpoint]),
1403
+ ];
1404
+ return {
1405
+ pub: {
1406
+ allow: [
1407
+ "$JS.API.INFO",
1408
+ // The key-pinned WRITE grants: the gate (provision create + barrier freeze/reopen CAS),
1409
+ // the serving ledger family (mint-fence stage + barrier revoke), and the registration's
1410
+ // two records-store keys (spec CAS + governance slot/promote). The value-publish carries
1411
+ // the key on the subject, so each grant names exactly this instance's keys.
1412
+ `$KV.${AUTH}.${gateKey}`,
1413
+ `$KV.${AUTH}.${credPrefix}.>`,
1414
+ ...recordKeys.map((k) => `$KV.${REC}.${k}`),
1415
+ // §13.7 contract-artifact publication (P2 item 1, 1c): the registration publishes the
1416
+ // endpoint's cluster document, closure manifests, and schema roots to the EPC store so
1417
+ // callers can fetch-verify-compile the registered digests. A digest subject is a SINGLE
1418
+ // hex token (`epc.<64hex>`), so the grant is the single-token `epc.*` form (matching
1419
+ // `contractPublisherGrants`), never the multi-token `epc.>`. Digest subjects cannot be
1420
+ // key-pinned pre-mint; the store's SHAPE is the defense (ensureContractStore): a digest
1421
+ // subject holds exactly one broker-immutable message (max_msgs_per_subject:1 +
1422
+ // discard-new-per-subject REJECTS a second publish regardless of the create-only header, so
1423
+ // a grant-holder CANNOT append a shadow over a published artifact), deny_delete/deny_purge
1424
+ // keep that message, and content addressing (verify-on-read, create-only-winner fallback)
1425
+ // makes a wrong-subject write unservable. NAMED RESIDUAL: for its one-shot lifetime the
1426
+ // executor can publish NEW digest-addressed artifacts at previously-unused subjects (a
1427
+ // bounded storage flood; unreferenced artifacts carry no authority) — it can NOT overwrite,
1428
+ // shadow, or replace an existing one.
1429
+ `${spacePrefix(space)}.epc.*`,
1430
+ // The lost-CAS verify-read `publishContractArtifact` runs when a re-publish (every re-up /
1431
+ // restart against an existing store) loses the create-only CAS: it fetches the recorded
1432
+ // artifact to confirm the idempotent no-op. BOTH Direct Get forms — the subject-scoped
1433
+ // `last_by_subj` (the fast path) and the bare stream form (the create-only-winner
1434
+ // `next_by_subj` fallback) — so the publish path never dies on a broker denial regardless
1435
+ // of stream config. Without this the manager exits on its SECOND boot (the tester's
1436
+ // upgrade-path regression). Reads public content-addressed artifacts only.
1437
+ `$JS.API.DIRECT.GET.${epcStreamName(space)}.${spacePrefix(space)}.epc.>`,
1438
+ `$JS.API.DIRECT.GET.${epcStreamName(space)}`,
1439
+ // Leader-served reads (the auth store is allow_direct=false): stream-scoped MSG.GET (the
1440
+ // barrier/fence read the gate + each epcred row), plus the ordered consumer the epcred
1441
+ // `keys()` enumeration binds. The records store IS direct-servable, so its reads add the
1442
+ // key-pinned DIRECT.GET forms beside the leader-served fallback. NAMED RESIDUAL: reads
1443
+ // any row in both stores for its one-shot lifetime (metadata, no bearer bytes).
1444
+ `$JS.API.STREAM.MSG.GET.KV_${AUTH}`,
1445
+ `$JS.API.CONSUMER.CREATE.KV_${AUTH}.>`,
1446
+ `$JS.API.CONSUMER.INFO.KV_${AUTH}.>`,
1447
+ `$JS.API.CONSUMER.DELETE.KV_${AUTH}.>`,
1448
+ ...recordKeys.map((k) => `$JS.API.DIRECT.GET.KV_${REC}.$KV.${REC}.${k}`),
1449
+ `$JS.API.STREAM.MSG.GET.KV_${REC}`,
1450
+ ],
1451
+ },
1452
+ sub: { allow: [`_INBOX_${pr.connId}.>`] },
1453
+ };
1454
+ }
1124
1455
  /** The ephemeral, TARGET-PINNED DEPROVISIONER permission set (#159 Part B) — the teardown counterpart
1125
1456
  * to {@link provisionerPermissions}, minted per departed agent inside the manager's `deprovision` tail
1126
1457
  * (`withProvisioner`-style: a fresh scoped cred per teardown is cheap). It deletes exactly the
@@ -1195,6 +1526,15 @@ function deliveryPermissions(space, pr) {
1195
1526
  // chat (the fan-out consumes the whole stream), so a stream-wide CHAT consumer grant is no
1196
1527
  // escalation. The catch-up ephemeral names (`cu_<owner>_<gen>`) are dynamic, so they can't be
1197
1528
  // name-pinned; CHAT-wide is correct here.
1529
+ //
1530
+ // BOTH forms, and the bare one is not redundant: a create carrying `filter_subjects` cannot encode
1531
+ // its filter in the subject, so the client publishes to the BARE `…CREATE.<CHAT>` while a named
1532
+ // consumer goes to the `.>` form. The mediated history read (`readHistory`) builds exactly that
1533
+ // multi-filter ephemeral, and with only the `.>` grant it fails with a Permissions Violation on the
1534
+ // bare subject — measured, not predicted. The observer profile already carries both for the same
1535
+ // reason. Grants the daemon nothing new in substance: it may already create any named consumer on
1536
+ // this stream and already reads all of it.
1537
+ `$JS.API.CONSUMER.CREATE.${CHAT}`,
1198
1538
  `$JS.API.CONSUMER.CREATE.${CHAT}.>`,
1199
1539
  `$JS.API.CONSUMER.DURABLE.CREATE.${CHAT}.>`,
1200
1540
  `$JS.API.CONSUMER.INFO.${CHAT}.>`,
@@ -1295,7 +1635,7 @@ function membershipObserverPermissions(accountId) {
1295
1635
  * (`auth.account.pub`). Mirrors {@link mintCreds} but issues into the system account. */
1296
1636
  export async function mintMembershipObserverCreds(auth, identity, opts = {}) {
1297
1637
  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.");
1638
+ 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
1639
  const signer = fromSeed(new TextEncoder().encode(auth.sys.signingSeed));
1300
1640
  const perms = membershipObserverPermissions(auth.account.pub);
1301
1641
  // Bounded exp (D5 slice 5): the observer is `rotation-renewed` — it carries the matrix's default
@@ -1328,7 +1668,7 @@ function connectionEvictorPermissions() {
1328
1668
  * only), same fail-loud when it's absent. Paired with the observer at `up`. */
1329
1669
  export async function mintConnectionEvictorCreds(auth, identity, opts = {}) {
1330
1670
  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.");
1671
+ 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
1672
  const signer = fromSeed(new TextEncoder().encode(auth.sys.signingSeed));
1333
1673
  // Bounded exp (D5 slice 5): `rotation-renewed`, same posture as the observer above.
1334
1674
  const validDates = userValidDates("connection-evictor", opts);
@@ -1347,6 +1687,53 @@ export async function mintConnectionEvictorCreds(auth, identity, opts = {}) {
1347
1687
  * NOTE (W4): the MEMORY resolver is one static whole-broker map, so every mutation rewrites all of
1348
1688
  * it. Concurrent add/remove of spaces needs a broker-authoritative inventory with generation/CAS
1349
1689
  * and atomic promotion above this function; this renderer is deliberately pure. */
1690
+ /**
1691
+ * Render the config for an OPEN (no-auth) broker.
1692
+ *
1693
+ * This exists so that no path reaches a listener without naming its transport. Open mode used to
1694
+ * start nats-server from bare CLI flags (`-js -sd … -p … -a …`) and never called `serverConfig` at
1695
+ * all, which meant the required `transport` union protected the auth path and was silent on the
1696
+ * open one: an operator could pass a cert and key, watch `up` print its normal banner, and get a
1697
+ * cleartext listener. That is the silent downgrade this feature exists to prevent, reachable by
1698
+ * someone who did everything right — so open mode renders a config too.
1699
+ *
1700
+ * It deliberately does NOT reuse `serverConfig`: that renders the operator, system account and
1701
+ * MEMORY resolver, none of which a no-auth broker should carry. What the two share is the thing
1702
+ * that matters — a REQUIRED transport, so the choice cannot be omitted on either path.
1703
+ *
1704
+ * IMPORTANT, and it must be said wherever open-mode TLS is surfaced to an operator: TLS ON AN
1705
+ * OPEN MESH GIVES CONFIDENTIALITY, NOT AUTHENTICATION. It hides traffic from a passive observer.
1706
+ * It does not verify who is connecting, because an open broker has no credentials to check — so
1707
+ * anyone who can reach the port still gets in, encrypted. It is a legitimate configuration for a
1708
+ * mesh crossing a network nobody controls, and it is NOT "secure" in the sense a reader will
1709
+ * assume from seeing `cotals://`. Describe it as the caveat it is rather than as a feature.
1710
+ */
1711
+ export function openServerConfig(opts) {
1712
+ const port = opts.port ?? 4222;
1713
+ const host = opts.host ?? "127.0.0.1";
1714
+ return `# Generated by \`cotal up\` - do not edit by hand.
1715
+ host: ${host}
1716
+ port: ${port}
1717
+ max_control_line: 65536
1718
+ ${renderTlsBlock(opts.transport)}jetstream { store_dir: ${JSON.stringify(opts.storeDir)} }
1719
+ `;
1720
+ }
1721
+ /** The one place a `tls{}` block is produced, shared by both broker renderers.
1722
+ *
1723
+ * `allow_non_tls` is deliberately never emitted. It is a TOP-LEVEL knob (nested inside `tls{}`
1724
+ * nats-server rejects it as an unknown field), and it turns the listener into mixed mode: INFO
1725
+ * then advertises `tls_available` instead of `tls_required`, and a client that declines to
1726
+ * upgrade is served in cleartext — precisely the credential exposure this transport closes.
1727
+ * There is no supported migration mode; enabling TLS is all-or-nothing. */
1728
+ function renderTlsBlock(transport) {
1729
+ if (transport.kind === "plaintext")
1730
+ return "";
1731
+ return `tls {
1732
+ cert_file: ${JSON.stringify(transport.certFile)}
1733
+ key_file: ${JSON.stringify(transport.keyFile)}
1734
+ }
1735
+ `;
1736
+ }
1350
1737
  export function serverConfig(broker, spaces, opts) {
1351
1738
  if (!spaces.length)
1352
1739
  throw new Error("serverConfig: at least one space account is required");
@@ -1361,6 +1748,14 @@ export function serverConfig(broker, spaces, opts) {
1361
1748
  }
1362
1749
  const port = opts.port ?? 4222;
1363
1750
  const host = opts.host ?? "127.0.0.1";
1751
+ // The websocket listener (item 6): LOCALHOST by default, no_tls for the dev loopback. Emitted only
1752
+ // when wsPort is set — a broker with no console session client opens no ws surface.
1753
+ const websocket = opts.wsPort === undefined ? "" : `websocket {
1754
+ host: ${opts.wsHost ?? "127.0.0.1"}
1755
+ port: ${opts.wsPort}
1756
+ no_tls: true
1757
+ }
1758
+ `;
1364
1759
  // A minted "agent" carries its full permission allow-list inline in its user JWT, which the
1365
1760
  // client sends in the CONNECT protocol line. With per-channel + JetStream-API grants that JWT
1366
1761
  // exceeds the 4 KB default max_control_line at ~2 channels, and the server then silently drops
@@ -1368,12 +1763,13 @@ export function serverConfig(broker, spaces, opts) {
1368
1763
  // agent JWT — but right-sized, not generous: the CONNECT line is parsed BEFORE auth, so the cap
1369
1764
  // is a per-connection pre-auth allocation under connection flooding. 64 KB clears a many-channel
1370
1765
  // agent JWT (~4–8 KB) with wide margin while keeping the pre-auth surface ~16× tighter than 1 MB.
1766
+ const tlsBlock = renderTlsBlock(opts.transport);
1371
1767
  return `# Generated by \`cotal up\` - do not edit by hand.
1372
1768
  host: ${host}
1373
1769
  port: ${port}
1374
1770
  max_control_line: 65536
1375
- jetstream { store_dir: ${JSON.stringify(opts.storeDir)} }
1376
- operator: ${broker.operator.jwt}
1771
+ ${tlsBlock}jetstream { store_dir: ${JSON.stringify(opts.storeDir)} }
1772
+ ${websocket}operator: ${broker.operator.jwt}
1377
1773
  system_account: ${broker.sys.pub}
1378
1774
  resolver: MEMORY
1379
1775
  resolver_preload: {