@cotal-ai/auth 0.90.1 → 0.92.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 (120) hide show
  1. package/dist/authority-client.d.ts +70 -3
  2. package/dist/authority-client.d.ts.map +1 -1
  3. package/dist/authority-client.js +113 -9
  4. package/dist/authority-client.js.map +1 -1
  5. package/dist/chat-parent-pin.d.ts +43 -0
  6. package/dist/chat-parent-pin.d.ts.map +1 -0
  7. package/dist/chat-parent-pin.js +115 -0
  8. package/dist/chat-parent-pin.js.map +1 -0
  9. package/dist/chat-session-authority.d.ts +120 -0
  10. package/dist/chat-session-authority.d.ts.map +1 -0
  11. package/dist/chat-session-authority.js +924 -0
  12. package/dist/chat-session-authority.js.map +1 -0
  13. package/dist/chat-session-row.d.ts +117 -0
  14. package/dist/chat-session-row.d.ts.map +1 -0
  15. package/dist/chat-session-row.js +358 -0
  16. package/dist/chat-session-row.js.map +1 -0
  17. package/dist/chat-session-store.d.ts +126 -0
  18. package/dist/chat-session-store.d.ts.map +1 -0
  19. package/dist/chat-session-store.js +392 -0
  20. package/dist/chat-session-store.js.map +1 -0
  21. package/dist/commands.d.ts +3 -1
  22. package/dist/commands.d.ts.map +1 -1
  23. package/dist/commands.js +527 -125
  24. package/dist/commands.js.map +1 -1
  25. package/dist/continuity.js +1 -1
  26. package/dist/continuity.js.map +1 -1
  27. package/dist/daemon-flags.d.ts +42 -0
  28. package/dist/daemon-flags.d.ts.map +1 -0
  29. package/dist/daemon-flags.js +12 -0
  30. package/dist/daemon-flags.js.map +1 -0
  31. package/dist/handle-inspect.d.ts +101 -0
  32. package/dist/handle-inspect.d.ts.map +1 -0
  33. package/dist/handle-inspect.js +50 -0
  34. package/dist/handle-inspect.js.map +1 -0
  35. package/dist/handle-verification.d.ts +75 -0
  36. package/dist/handle-verification.d.ts.map +1 -0
  37. package/dist/handle-verification.js +135 -0
  38. package/dist/handle-verification.js.map +1 -0
  39. package/dist/idp.d.ts +23 -1
  40. package/dist/idp.d.ts.map +1 -1
  41. package/dist/idp.js +54 -8
  42. package/dist/idp.js.map +1 -1
  43. package/dist/index.d.ts +3 -2
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +3 -2
  46. package/dist/index.js.map +1 -1
  47. package/dist/ledger.d.ts +58 -5
  48. package/dist/ledger.d.ts.map +1 -1
  49. package/dist/ledger.js +126 -6
  50. package/dist/ledger.js.map +1 -1
  51. package/dist/lifecycle-registry.d.ts +1 -70
  52. package/dist/lifecycle-registry.d.ts.map +1 -1
  53. package/dist/lifecycle-registry.js +4 -35
  54. package/dist/lifecycle-registry.js.map +1 -1
  55. package/dist/login.d.ts +9 -0
  56. package/dist/login.d.ts.map +1 -1
  57. package/dist/login.js +53 -8
  58. package/dist/login.js.map +1 -1
  59. package/dist/manager-authority.d.ts +59 -3
  60. package/dist/manager-authority.d.ts.map +1 -1
  61. package/dist/manager-authority.js +185 -4
  62. package/dist/manager-authority.js.map +1 -1
  63. package/dist/native-service-gate.d.ts +14 -3
  64. package/dist/native-service-gate.d.ts.map +1 -1
  65. package/dist/native-service-gate.js +34 -20
  66. package/dist/native-service-gate.js.map +1 -1
  67. package/dist/native-service-name.d.ts +7 -0
  68. package/dist/native-service-name.d.ts.map +1 -1
  69. package/dist/native-service-name.js +15 -6
  70. package/dist/native-service-name.js.map +1 -1
  71. package/dist/permissions.js +4 -0
  72. package/dist/permissions.js.map +1 -1
  73. package/dist/platform-control.d.ts.map +1 -1
  74. package/dist/platform-control.js +1 -0
  75. package/dist/platform-control.js.map +1 -1
  76. package/dist/provider.d.ts +23 -0
  77. package/dist/provider.d.ts.map +1 -1
  78. package/dist/provider.js +252 -44
  79. package/dist/provider.js.map +1 -1
  80. package/dist/run-cancel-observation.d.ts +50 -0
  81. package/dist/run-cancel-observation.d.ts.map +1 -0
  82. package/dist/run-cancel-observation.js +146 -0
  83. package/dist/run-cancel-observation.js.map +1 -0
  84. package/dist/service-adapter.d.ts +6 -0
  85. package/dist/service-adapter.d.ts.map +1 -0
  86. package/dist/service-adapter.js +120 -0
  87. package/dist/service-adapter.js.map +1 -0
  88. package/dist/service-custody.d.ts +29 -20
  89. package/dist/service-custody.d.ts.map +1 -1
  90. package/dist/service-custody.js +37 -167
  91. package/dist/service-custody.js.map +1 -1
  92. package/dist/service-endpoint-authority.d.ts +70 -0
  93. package/dist/service-endpoint-authority.d.ts.map +1 -0
  94. package/dist/service-endpoint-authority.js +257 -0
  95. package/dist/service-endpoint-authority.js.map +1 -0
  96. package/dist/service-host-contract.d.ts +158 -16
  97. package/dist/service-host-contract.d.ts.map +1 -1
  98. package/dist/service-host-contract.js +330 -1
  99. package/dist/service-host-contract.js.map +1 -1
  100. package/dist/service-host-ops.d.ts +550 -0
  101. package/dist/service-host-ops.d.ts.map +1 -0
  102. package/dist/service-host-ops.js +2180 -0
  103. package/dist/service-host-ops.js.map +1 -0
  104. package/dist/service-write.d.ts +141 -0
  105. package/dist/service-write.d.ts.map +1 -0
  106. package/dist/service-write.js +179 -0
  107. package/dist/service-write.js.map +1 -0
  108. package/dist/service.d.ts +115 -3
  109. package/dist/service.d.ts.map +1 -1
  110. package/dist/service.js +697 -92
  111. package/dist/service.js.map +1 -1
  112. package/dist/store.d.ts +10 -0
  113. package/dist/store.d.ts.map +1 -1
  114. package/dist/store.js +25 -8
  115. package/dist/store.js.map +1 -1
  116. package/dist/token.d.ts +1 -1
  117. package/dist/token.d.ts.map +1 -1
  118. package/dist/token.js +4 -1
  119. package/dist/token.js.map +1 -1
  120. package/package.json +16 -7
package/dist/service.js CHANGED
@@ -29,7 +29,8 @@ import { createToolResultConsentSigner, makeToolResultConsentRecorder } from "./
29
29
  * 127.0.0.1; TLS terminates at a reverse proxy — in-process TLS was rejected: it duplicates cert
30
30
  * renewal and forks deploy). It serves ONLY `GET /health`, `GET /jwks`, `POST /exchange`,
31
31
  * `POST /manager-service-authority`, and `GET /.well-known/cotal-mesh` (the generated discovery
32
- * bundle); everything else 404s. Its
32
+ * bundle), plus, only with `serviceEndpoints`, `/service-endpoint-authority` on both faces;
33
+ * everything else 404s. Its
33
34
  * `/exchange` demands NO capability — honestly: the 0600 cap is a same-uid file-ACL boundary with
34
35
  * no remote meaning, so requiring its bytes from a remote caller would prove nothing. The proof on
35
36
  * the public face is the credential itself: the human arm presents an EdDSA IdP JWT verified
@@ -62,7 +63,7 @@ import { resolve } from "node:path";
62
63
  import { connect, credsAuthenticator } from "@nats-io/transport-node";
63
64
  import { jetstream, jetstreamManager } from "@nats-io/jetstream";
64
65
  import { Kvm } from "@nats-io/kv";
65
- import { CotalEndpoint, deprovisionAgent, isConcreteChannel, provisionAgentDurables, contractDigest, contractRefToHex, contractStoreContext, endpointToken, fetchContractArtifact, verifyClusterManifest, verifyClusterRoot, admissionBucket, admissionMediatorGrants, assertDerivedOwnerToken, assertLifecycleToken, assertValidOwnerToken, authorizeTrustedServeSnapshot, commitSiblingIssuance, credsClaims, EpEnvelopeError, ensureAuthorityStores, epAuthBucket, isReachable, jwtFromCreds, managedRetirementOpId, mintCreds, mintPublicUserJwt, newIdentity, newTakeoverId, observeHostedRunAttempt, openRecordsBucket, parseEndpointGate, parseServiceSpec, parseServiceStatus, rawDigest, parseSecretStoreIdentity, readCheckpointStatus, readRunAdmission, readRunRevocation, readRunRecord, readSvcRecordLeader, reconcileEndpointGate, replayRunJournal, sameSecretStoreIdentity, recordSpecKey, recordStatusKey, RECORD_KINDS, recordsBucket, remoteManagerActors, retirementFrontierStreams, runDriverCaller, serveIssuanceGateKv, standaloneConnectOpts, STANDING_RENEWABLE_TTL_SEC, withIssuerSession, acceptedReadGrant, actorLedgerSource, connectionAcceptedToken, importNativeSubjectPermissions, mintGeneration, parseActorLedgerSource, writeAcceptedRow, invokeCommand, resolveService, contractArtifactCanonicalBytes, DEV_OWNER, endpointRegistrationBarrier, mintLifecycleUid, principalKey, provisionEndpointGateOpen, publishContractArtifact, registerServiceInstance, registerServingInstance, epgateKey, isCasLoss } from "@cotal-ai/core";
66
+ import { CotalEndpoint, deprovisionAgent, dlvDurable, dlvStream, dmDurable, dmStream, isConcreteChannel, provisionAgentDurables, contractDigest, contractRefToHex, contractStoreContext, endpointToken, fetchContractArtifact, verifyClusterManifest, verifyClusterRoot, admissionBucket, admissionMediatorGrants, assertDerivedOwnerToken, assertLifecycleToken, assertValidOwnerToken, authorizeTrustedServeSnapshot, commitSiblingIssuance, credsClaims, EpEnvelopeError, ensureAuthorityStores, epAuthBucket, isReachable, jwtFromCreds, managedRetirementOpId, mintCreds, mintPublicUserJwt, newIdentity, newTakeoverId, observeHostedRunAttempt, openRecordsBucket, parseEndpointGate, parseServiceSpec, parseServiceStatus, rawDigest, parseSecretStoreIdentity, readCheckpointStatus, readRunAdmission, readRunRevocation, readRunRecord, readSvcRecordLeader, reconcileEndpointGate, replayRunJournal, sameSecretStoreIdentity, recordSpecKey, recordStatusKey, RECORD_KINDS, recordsBucket, remoteManagerActors, retirementFrontierStreams, runDriverCaller, serveIssuanceGateKv, standaloneConnectOpts, STANDING_RENEWABLE_TTL_SEC, withIssuerSession, acceptedReadGrant, actorLedgerSource, connectionAcceptedToken, importNativeSubjectPermissions, mintGeneration, parseActorLedgerSource, writeAcceptedRow, invokeCommand, resolveService, contractArtifactCanonicalBytes, DEV_OWNER, endpointRegistrationBarrier, mintLifecycleUid, principalKey, provisionEndpointGateOpen, publishContractArtifact, registerServiceInstance, registerServingInstance, parseServiceAuthorityEnvelope, SERVICE_AUTHORITY_MAX_BYTES, epgateKey, isCasLoss, sameManagedGrantCustody, parseServiceRegistrationWitness, serviceWitnessDigest } from "@cotal-ai/core";
66
67
  import { findCotalRoot, loadManagerInstanceIdentity, userAuthStateDir, workspaceSecretStore, createAuthInstanceIdentity, loadAuthInstanceIdentity } from "@cotal-ai/workspace";
67
68
  import { decodeJwt } from "jose";
68
69
  import { deriveOwnerForIdpSubject, platformControlOwner } from "./derive.js";
@@ -71,11 +72,11 @@ import { OwnedConnections } from "./owned-connections.js";
71
72
  import { makePlatformSupervisorAuthority, makePlatformSupervisorEnd, platformSupervisorEnded, requirePlatformSupervisorAdmin } from "./platform-supervisor.js";
72
73
  import { startAuthCallout } from "./callout.js";
73
74
  import { createIdpBridge, verifyIdpToken, verifyToolResultHumanProof } from "./idp.js";
74
- import { PUBLIC_EXCHANGE_VIEWS, assertTransferWriterClaim } from "./token.js";
75
+ import { PUBLIC_EXCHANGE_VIEWS, assertTransferWriterClaim, validateUserToken } from "./token.js";
75
76
  import { grantCoordinates, verifySessionRedemption } from "./session-redemption.js";
76
77
  import { pinnedJwksResolver } from "./issuer.js";
77
78
  import { calloutPermissions } from "./permissions.js";
78
- import { admitRemoteRun, authorizeRemoteManagerRenewal, authorizeRemoteRunAttempt, holdsRunFence, issueRemoteManagerAuthority, observedRunRequest, parseRemoteRunAdmissionRequest, parseRemoteRunAttemptRequest, parseRemoteRunRevokeRequest, revokeRemoteRun } from "./manager-authority.js";
79
+ import { handleRemoteRunCancel, parseRemoteRunCancelRequest, admitRemoteRun, handleRemoteRunSubmission, authorizeRemoteManagerRenewal, authorizeRemoteRunAttempt, holdsRunFence, issueRemoteManagerAuthority, observedRunRequest, parseRemoteRunAdmissionRequest, parseRemoteRunAttemptRequest, parseRemoteRunRevokeRequest, revokeRemoteRun } from "./manager-authority.js";
79
80
  import { authorizeRemoteRetainedAgentValidation, completeRemoteRetainedAgentValidation, remoteManagerCurrentRegistrationProof } from "./retained-manager-validation.js";
80
81
  import { authorizeRemoteManagedAgentEnrollment, authorizeRemoteManagedAgentPrepareRetirement, authorizeRemoteManagedAgentRuntimeCreate, authorizeRemoteManagedAgentRuntimeStatus } from "./managed-agent-enrollment.js";
81
82
  import { authorizeRemoteManagerGoalIndexScan, completeRemoteManagerGoalIndexScan } from "./manager-goal-index.js";
@@ -83,11 +84,16 @@ import { authorizeRemoteManagerAdmin } from "./manager-admin-authorization.js";
83
84
  import { authorizeRemoteManagerMaintenance, completeRemoteManagerMaintenance } from "./manager-maintenance.js";
84
85
  import { validateRetainedManagedAgent } from "./continuity.js";
85
86
  import { reconstructRemoteManagerServeGrant, remoteManagerServeGrantFromCluster, remoteManagerSurface } from "./manager-contract.js";
86
- import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, platformReadinessGrants, remoteManagerIssuerGrants, remoteManagerRegistrationProof, registrationExecutorGrants, servedRunRequestSubjects } from "./authority-client.js";
87
+ import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, platformReadinessGrants, remoteManagerIssuerGrants, remoteManagerRegistrationProof, registrationExecutorGrants, servedRunRequestSubjects, servedRunCancelRequestSubject, serviceCalloutWriterGrants, serviceHostDiscoveryGrants } from "./authority-client.js";
88
+ import { createNativeServiceCalloutAuthority } from "./native-service-callout.js";
87
89
  import { authorizeConnectCredential } from "./connect-reader.js";
90
+ import { chatAwareAclResolver, openChatSessionAuthority } from "./chat-session-authority.js";
88
91
  import { makeNativeIssuanceRecorder, pruneNativeIssuance } from "./native-issuance.js";
89
92
  import { ensureRootCredential } from "./root-credential.js";
90
93
  import { activateLifecycleAtUid, observeGate, openLifecycleRegistry, readLifecycleHeadForOperation } from "./lifecycle-registry.js";
94
+ import { openServiceHostEffect, openServiceHostOperations } from "./service-host-ops.js";
95
+ import { SERVICE_ENDPOINT_AUTHORITY_PATH, answerServiceAuthority, serviceAuthorityDenied, serviceAuthorityInfo } from "./service-endpoint-authority.js";
96
+ import { validateServiceRenewalProof } from "./service-renewal.js";
91
97
  import { openAuthLedgerScannerCandidate } from "./ledger-scanner.js";
92
98
  import { openRecordsScannerCandidate } from "./records-scanner.js";
93
99
  import { acquirePlaneClaim, makeDeliveryAdminPlaneOracle, makeDeliveryAdminPrincipalOracle, scannerDeathCopy, planeClaimRefusal } from "./plane-claim.js";
@@ -97,16 +103,21 @@ import { resumeAgentRetirement, runAgentRetirementBarrier } from "./retirement-b
97
103
  import { makeRetirementCleaners } from "./retirement-cleaner.js";
98
104
  import { makeDrainRepairers } from "./drain-repair.js";
99
105
  import { serializedFor } from "./serialized.js";
106
+ import { RunCancelObservations } from "./run-cancel-observation.js";
107
+ import { readCancellationAdmission } from "@cotal-ai/core";
100
108
  import { joinOrStartRetirement, openAuthAdminListener } from "./auth-admin.js";
101
109
  import { AUTH_SERVICE_ENDPOINT, authClusterArtifacts, authContractArtifactValues } from "./auth-service-contract.js";
102
110
  import { drainTargetForEndpoint, openAdmissionMediator } from "./admission-mediator.js";
103
- import { AGENT_BEARER_TTL_SEC, assertWithinSpawnerGrant, findInteractiveActor, findManagedActor, findActorUnified, grantManagedActor, ledgerAclResolver, ledgerActorSourceIsLive, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, revokeManagedActorAt, } from "./ledger.js";
111
+ import { AGENT_BEARER_TTL_SEC, assertWithinSpawnerGrant, findInteractiveActor, findManagedActor, findActorUnified, ledgerAclResolver, ledgerActorSourceIsLive, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, revokeManagedActorIf, withManagedGrantTransaction, } from "./ledger.js";
104
112
  import { AUTH_PROVIDER_NAME, claimAuthInstanceIdentity, clearAuthServiceInfo, dataAccountAuth, loadCalloutAuth, loadIssuer, loadOwnerSecret, loadPinnedIdp, loadServiceKeys, saveAuthServiceInfo, spaceIssuer, } from "./store.js";
105
113
  /** JWKS max-age seconds — the cache contract's knob. Exported so a rotation tool can compute the
106
114
  * retire floor (max-age + max bearer TTL) from it. */
107
115
  export const JWKS_MAX_AGE_SEC = 300;
108
116
  /** Loopback-only operator route used by `cotal actor grant/revoke` before rotating or deleting an interactive lifecycle. */
109
117
  export const INTERACTIVE_RETIRE_PATH = "/interactive-lifecycle/retire";
118
+ /** The user-mode chat-session routes, on the loopback face (capability-gated) and the public face.
119
+ * Each takes a fresh IdP proof; `bearer` and `retire` also take the session's holder proof. */
120
+ export const CHAT_SESSION_PATHS = Object.freeze({ open: "/chat-session/open", bearer: "/chat-session/bearer", retire: "/chat-session/retire" });
110
121
  /** Loopback-only host route that finishes a managed lifecycle's terminal retirement after the host
111
122
  * revoked its grant, when the remote manager that should have requested it is gone (#2070). */
112
123
  export const MANAGED_RETIRE_PATH = "/managed-lifecycle/retire";
@@ -227,6 +238,14 @@ async function supervisorGeneration(session, space, owner, instanceId, lifecycle
227
238
  throw new EpEnvelopeError("permission-denied", "platform supervisor accepted row names another owner or lifecycle");
228
239
  return accepted.ref;
229
240
  }
241
+ function runtimeSelection(options) {
242
+ if (!options || typeof options !== "object" || Array.isArray(options))
243
+ throw new Error("auth-service runtime options must be an object");
244
+ const selection = options.interactiveManagerSelection;
245
+ if (selection !== undefined && selection !== "local-primary")
246
+ throw new Error('interactiveManagerSelection must be "local-primary" when present');
247
+ return selection;
248
+ }
230
249
  /**
231
250
  * Open the authority plane — the PRODUCTION connect/exchange composition (exported so the live
232
251
  * deny-new smoke exercises exactly what the daemon runs). Boot order is the readiness contract:
@@ -242,6 +261,28 @@ async function supervisorGeneration(session, space, owner, instanceId, lifecycle
242
261
  export async function openAuthAuthorityPlane(opts) {
243
262
  return await openAuthAuthorityPlaneWithCustody(opts);
244
263
  }
264
+ /** The hosted door over a plane's service endpoint host: refused before entry unless the context is
265
+ * ready, and mapped to the same engine so the internal accessor still finds it. */
266
+ function hostedServiceEndpoints(host, ready) {
267
+ const door = async (request) => { ready(); return await host(request); };
268
+ const engine = SERVICE_HOST_ENGINES.get(host), callout = SERVICE_CALLOUTS.get(host);
269
+ if (engine !== undefined)
270
+ SERVICE_HOST_ENGINES.set(door, engine);
271
+ if (callout !== undefined)
272
+ SERVICE_CALLOUTS.set(door, callout);
273
+ return door;
274
+ }
275
+ /** Package-internal (not exported from the package index): the engine behind a plane's service
276
+ * endpoint host, whose register admission only a later owner-verifying route may call. */
277
+ const SERVICE_HOST_ENGINES = new WeakMap();
278
+ export function serviceHostEngineOf(host) {
279
+ return SERVICE_HOST_ENGINES.get(host);
280
+ }
281
+ /** Package-internal: the native service callout authority composed with a plane's service host. */
282
+ const SERVICE_CALLOUTS = new WeakMap();
283
+ export function serviceHostCalloutOf(host) {
284
+ return SERVICE_CALLOUTS.get(host);
285
+ }
245
286
  /** The context's custody callback is private. Failed construction unwinds from one place. */
246
287
  async function openAuthAuthorityPlaneWithCustody(opts, onConnection) {
247
288
  const unwind = [];
@@ -256,7 +297,9 @@ async function openAuthAuthorityPlaneWithCustody(opts, onConnection) {
256
297
  }
257
298
  /** The body of the plane opener, which owns the unwind of a failed construction. */
258
299
  async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
300
+ const interactiveManagerSelection = runtimeSelection(opts);
259
301
  const { server, space, dataAccount, log } = opts;
302
+ const cancelObservations = new RunCancelObservations(space, opts.runCancelLimits);
260
303
  const standingTtl = opts.standingRenewableTtlSeconds ?? STANDING_RENEWABLE_TTL_SEC;
261
304
  if (!Number.isSafeInteger(standingTtl) || standingTtl < 5 || standingTtl > STANDING_RENEWABLE_TTL_SEC) {
262
305
  throw new Error(`standingRenewableTtlSeconds must be an integer between 5 and ${STANDING_RENEWABLE_TTL_SEC} seconds (got ${standingTtl})`);
@@ -354,12 +397,17 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
354
397
  // Mid-life disconnect = the FENCING event (security req: an owned scanner that dies must not
355
398
  // leave the plane half-running). First death wins: fence the guard with the ux state-3 copy,
356
399
  // close the sibling so a successor's reclaim isn't blocked by a half-dead pair, log loud.
400
+ let serviceHost;
357
401
  const fenceOnDeath = (role, sibling) => {
358
- if (closing)
359
- return;
360
402
  const copy = scannerDeathCopy(space, role);
403
+ // A closing plane does not go fatal, but a service host call it is still draining stops here.
404
+ if (closing) {
405
+ serviceHost?.trip(copy);
406
+ return;
407
+ }
361
408
  hold?.fence(copy);
362
409
  fatalReason = fatalReason ?? copy;
410
+ serviceHost?.trip(copy);
363
411
  fireFatal(copy);
364
412
  log(`auth-plane: ${copy}`);
365
413
  void sibling().catch(() => { });
@@ -435,11 +483,20 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
435
483
  unwind.push(() => authAdmin.close());
436
484
  const recordToolResultConsent = makeToolResultConsentRecorder(registryStores(registry).authKv, () => refuseIfFenced(), space);
437
485
  const fileArm = ledgerAuthorizeConnect(opts.dir);
486
+ // A chat principal (its alias is in the chat row space) is checked by the chat arm: its active
487
+ // row, its parent pin and its holder index. Every other principal by the grant-file arm.
488
+ const connectArm = async (t) => {
489
+ if (chat.isChatPrincipal(t.owner, t.act.actor))
490
+ await chat.authorizeConnect(t);
491
+ else
492
+ fileArm(t);
493
+ };
438
494
  // The callout's issuance evidence: create-only natkey row, then the connect authority re-read
439
- // in the same stream before the minted JWT may leave.
495
+ // in the same stream before the minted JWT may leave. For a chat session this re-read is the
496
+ // post-sign fence: its own head and root credential, its parent pin and its holder index.
440
497
  const recordIssuance = makeNativeIssuanceRecorder(registry, async (t) => {
441
498
  refuseIfFenced();
442
- fileArm(t);
499
+ await connectArm(t);
443
500
  await authorizeConnectCredential(reader.current(), t, Date.now);
444
501
  });
445
502
  // Expired evidence goes at boot (a failure refuses the open) and then on the plane's own timer;
@@ -455,11 +512,15 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
455
512
  return;
456
513
  pruneTimer = setTimeout(() => {
457
514
  pruneFlight = pruneNativeIssuance(registry, Math.floor(Date.now() / 1000)).then(schedulePrune, (e) => {
458
- if (closing)
459
- return;
460
515
  const copy = `native-issuance cleanup failed: ${e instanceof Error ? e.message : String(e)}`;
516
+ // As for a scanner death: a closing plane does not go fatal, but a call it is draining stops.
517
+ if (closing) {
518
+ serviceHost?.trip(copy);
519
+ return;
520
+ }
461
521
  hold?.fence(copy);
462
522
  fatalReason = fatalReason ?? copy;
523
+ serviceHost?.trip(copy);
463
524
  fireFatal(copy);
464
525
  log(`auth-plane: ${copy}`);
465
526
  });
@@ -468,6 +529,84 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
468
529
  const stopPrune = async () => { clearTimeout(pruneTimer); await pruneFlight; };
469
530
  unwind.push(async () => { closing = true; await stopPrune(); });
470
531
  schedulePrune();
532
+ // The service endpoint host, default off. Its discovery client only reads, on the ordinary
533
+ // renewing profile: a broker loss makes its reads fail until it reconnects, and a renewal failure
534
+ // closes it for good, after which decide and revoke answer unavailable until the plane is opened
535
+ // again. The engine shares this plane's hold; both fatal sites above trip it.
536
+ let discoveryClient, calloutWriter;
537
+ // Every effect connection the host opened whose close has not succeeded, kept past its call's
538
+ // settlement: plane close and a failed construction close it again and fail while any remains.
539
+ const openEffects = new Set();
540
+ const closeEffects = async () => {
541
+ const failed = [];
542
+ for (const e of [...openEffects]) {
543
+ try {
544
+ await e.close();
545
+ }
546
+ catch (err) {
547
+ failed.push(err);
548
+ }
549
+ }
550
+ if (failed.length > 0)
551
+ throw new AggregateError(failed, `${failed.length} service host effect connection(s) did not close`);
552
+ };
553
+ if (opts.serviceEndpoints !== undefined) {
554
+ const label = `cotal:service-host-discovery:${space}`;
555
+ const d = discoveryClient = await openAuthorityClient({ onConnection, server, space, dataAccount, label, grants: (id) => serviceHostDiscoveryGrants(space, id), log,
556
+ onRenewalFailure: () => {
557
+ log(`${label}: credential renewal failed - service host discovery is closed until the auth plane is opened again`);
558
+ void discoveryClient?.close().catch(() => { });
559
+ } });
560
+ unwind.push(() => d.close());
561
+ unwind.push(() => closeEffects());
562
+ const discovery = await openLifecycleRegistry(d.nc, space);
563
+ await opts.probeServiceHost?.discovery?.({ registry: discovery, kill: () => d.close() });
564
+ const probeEffect = opts.probeServiceHost?.effect;
565
+ // Serving reads registered closures from the contract store on the issuer's connection, which
566
+ // holds only the store's exact-subject Direct Get.
567
+ const contractStore = await contractStoreContext(remoteIssuer.nc, space);
568
+ const readContractArtifact = async (digest) => {
569
+ const bytes = await fetchContractArtifact(contractStore, contractRefToHex(digest));
570
+ if (!bytes)
571
+ throw new EpEnvelopeError("failed-precondition", `the registered contract artifact ${digest} is not readable`);
572
+ return JSON.parse(new TextDecoder().decode(bytes));
573
+ };
574
+ const engine = await openServiceHostOperations({
575
+ discovery, hold: hold, accountPublicKey: dataAccount.pub, ledgerDir: opts.dir, startup: opts.serviceEndpoints.startup,
576
+ serving: { readContractArtifact },
577
+ openEffect: async (scope) => {
578
+ const opened = await openServiceHostEffect({ onConnection, server, space, dataAccount, log }, scope);
579
+ const effect = { ...opened, close: async () => { await opened.close(); openEffects.delete(effect); } };
580
+ openEffects.add(effect);
581
+ if (probeEffect !== undefined) {
582
+ try {
583
+ await probeEffect(effect, scope);
584
+ }
585
+ catch (e) {
586
+ await effect.close().catch(() => { });
587
+ throw e;
588
+ }
589
+ }
590
+ return effect;
591
+ },
592
+ });
593
+ unwind.push(() => engine.close());
594
+ serviceHost = engine;
595
+ SERVICE_HOST_ENGINES.set(engine.handle, engine);
596
+ // A fence while the engine was opening latches it now.
597
+ if (fatalReason !== undefined)
598
+ engine.trip(fatalReason);
599
+ // The native service callout: a service bearer's connect mints only for the current bound
600
+ // assignment its witness names, read from the engine, on a writer that stages and touches the
601
+ // endpoint gate and credential rows. A fenced plane or a latched engine refuses it.
602
+ const calloutLabel = `cotal:service-callout:${space}`;
603
+ const cw = calloutWriter = await openAuthorityClient({ onConnection, server, space, dataAccount, label: calloutLabel, grants: (id) => serviceCalloutWriterGrants(space, id), log });
604
+ unwind.push(() => cw.close());
605
+ SERVICE_CALLOUTS.set(engine.handle, createNativeServiceCalloutAuthority({
606
+ registry: await openLifecycleRegistry(cw.nc, space), reader: () => reader.current(), dataAccount,
607
+ resolveAssignment: (witness) => engine.serviceBinding(witness), readClusterArtifact: readContractArtifact, assertOpen: () => refuseIfFenced(),
608
+ }));
609
+ }
471
610
  const recordsJsm = await jetstreamManager(remoteIssuer.nc);
472
611
  const authKv = await new Kvm(remoteIssuer.nc).open(epAuthBucket(space));
473
612
  const managerIssuanceGate = (instanceId) => serveIssuanceGateKv(authKv, space, { endpoint: "manager", instanceId });
@@ -561,6 +700,24 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
561
700
  throw new Error(`manager instance ${instanceId} is ${verdict}`);
562
701
  return instanceId;
563
702
  }
703
+ // A trusted host may choose the local Manager up front for human defaults. Explicit pins
704
+ // above and managed-agent calls keep their existing selection; no remote is a substitute.
705
+ if (caller === "human" && interactiveManagerSelection === "local-primary") {
706
+ let local;
707
+ try {
708
+ local = loadLocalManager();
709
+ }
710
+ catch {
711
+ throw new Error("interactive local-primary manager identity could not be read");
712
+ }
713
+ if (!local)
714
+ throw new Error("interactive local-primary manager identity is absent");
715
+ const instanceId = assertLifecycleToken(local.instanceId, "managerInstanceId");
716
+ const verdict = await managerGate(owner, instanceId);
717
+ if (verdict !== "candidate")
718
+ throw new Error(`interactive local-primary manager instance ${instanceId} is ${verdict}`);
719
+ return instanceId;
720
+ }
564
721
  const { instances } = await listManagerInstances(owner);
565
722
  const remoteInstances = instances.filter((i) => i.placement === "remote").map((i) => i.instanceId);
566
723
  const remoteLive = instances.filter((i) => i.placement === "remote" && i.state === "candidate").map((i) => i.instanceId);
@@ -593,6 +750,12 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
593
750
  // envelope asked (SPEC 14.8): the forward's coordinates are the manager's word. The manager
594
751
  // forwards from inside the handler serving the request, so an observation older than the window
595
752
  // has no forward left to bind; the window also bounds the table.
753
+ remoteIssuer.nc.subscribe(servedRunCancelRequestSubject(space), {
754
+ callback: (error, message) => {
755
+ if (!error)
756
+ cancelObservations.observe(message.subject, message.data);
757
+ },
758
+ });
596
759
  const observedRunRequests = new Map();
597
760
  for (const subject of servedRunRequestSubjects(space))
598
761
  remoteIssuer.nc.subscribe(subject, {
@@ -626,12 +789,94 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
626
789
  const creds = await mintCreds(issuerAuth(), newIdentity(), "deprovisioner", { deprovisionTarget: { principal: targetId, lifecycleUid, memberChannels } });
627
790
  await deprovisionAgent({ servers: server, space, targetId, lifecycleUid, memberChannels, creds });
628
791
  };
792
+ // A chat session's footprint (admission a7): its own bind-only DM and DELIVER durables and the
793
+ // read-ACL row the delivery daemon authorizes its history and durable joins from, written by a
794
+ // short-lived provisioner and read back on that same connection (part of a8).
795
+ const provisionChatFootprint = async ({ owner, actor, lifecycleUid, read }) => {
796
+ const identity = newIdentity();
797
+ let conn;
798
+ const provisioner = new CotalEndpoint({
799
+ space,
800
+ servers: server,
801
+ onConnection: (nc) => { conn = nc; onConnection?.(nc, `cotal:chat-provisioner:${space}`); },
802
+ channels: [],
803
+ creds: await mintCreds(issuerAuth(), identity, "provisioner"),
804
+ card: { id: identity.id, name: "provisioner", role: "provisioner", kind: "endpoint" },
805
+ registerPresence: false,
806
+ watchPresence: false,
807
+ watchChannels: false,
808
+ consume: false,
809
+ });
810
+ await provisioner.start();
811
+ try {
812
+ await provisionAgentDurables(provisioner, { owner, actor, lifecycleUid }, { subscribe: [], allowSubscribe: read });
813
+ if (conn === undefined)
814
+ throw new EpEnvelopeError("unavailable", "the chat provisioner connection is unavailable for the read-back", undefined, "unknown");
815
+ const jsm = await jetstreamManager(conn);
816
+ await jsm.consumers.info(dmStream(space), dmDurable(owner, actor, lifecycleUid));
817
+ await jsm.consumers.info(dlvStream(space), dlvDurable(owner, actor, lifecycleUid));
818
+ const acl = await provisioner.aclForOwner(principalKey(owner, actor).key, lifecycleUid);
819
+ if (acl === undefined || JSON.stringify(acl) !== JSON.stringify(read))
820
+ throw new EpEnvelopeError("unavailable", `the chat session's read-ACL row did not read back for ${lifecycleUid}`, undefined, "unknown");
821
+ }
822
+ finally {
823
+ await provisioner.stop();
824
+ }
825
+ };
826
+ const chat = openChatSessionAuthority({
827
+ space,
828
+ dir: opts.dir,
829
+ registry,
830
+ managerInstance: `auth-service:${space}`,
831
+ retireLifecycle: async ({ owner, actor, lifecycleUid, opId }) => {
832
+ const flight = joinOrStartRetirement(retirementFlights, barrierReg, { owner, actor, lifecycleUid, opId, frontierStreams: retirementFrontierStreams(space) }, retirement);
833
+ if (flight === undefined)
834
+ throw new EpEnvelopeError("conflict", `chat retirement operation ${opId} is already in flight for different coordinates`);
835
+ await flight;
836
+ },
837
+ provisionFootprint: provisionChatFootprint,
838
+ deprovisionFootprint: ({ owner, actor, lifecycleUid, memberChannels }) => deprovisionManagedFootprint(owner, actor, lifecycleUid, memberChannels),
839
+ now: Date.now,
840
+ log,
841
+ });
842
+ // One bounded chat sweep pass per interval. A failed pass is logged and the next one retries; it
843
+ // never fences the plane, because every chat authority decision re-reads its own records.
844
+ const chatSweepMs = opts.chatSweepMs ?? 60_000;
845
+ if (!Number.isSafeInteger(chatSweepMs) || chatSweepMs < 100 || chatSweepMs > 3_600_000)
846
+ throw new Error(`chatSweepMs must be an integer between 100 and 3600000 (got ${chatSweepMs})`);
847
+ let chatSweepTimer;
848
+ let chatSweepFlight = Promise.resolve();
849
+ const scheduleChatSweep = () => {
850
+ if (closing || fatalReason !== undefined)
851
+ return;
852
+ chatSweepTimer = setTimeout(() => {
853
+ chatSweepFlight = chat.sweepPass().catch((e) => {
854
+ if (!closing)
855
+ log(`auth-plane: chat sweep pass failed: ${e instanceof Error ? e.message : String(e)}`);
856
+ }).then(scheduleChatSweep);
857
+ }, chatSweepMs);
858
+ };
859
+ const stopChatSweep = async () => { clearTimeout(chatSweepTimer); await chatSweepFlight; };
860
+ unwind.push(async () => { closing = true; await stopChatSweep(); });
861
+ scheduleChatSweep();
629
862
  return {
630
863
  authorizeConnect: async (t) => {
631
864
  refuseIfFenced();
632
- fileArm(t);
865
+ await connectArm(t);
633
866
  await authorizeConnectCredential(reader.current(), t, Date.now);
634
867
  },
868
+ openChatSession: async (args) => {
869
+ refuseIfFenced();
870
+ return chat.admit(args);
871
+ },
872
+ chatSessionBearer: async (args) => {
873
+ refuseIfFenced();
874
+ return chat.exchange(args);
875
+ },
876
+ retireChatSession: async (args) => {
877
+ refuseIfFenced();
878
+ return chat.retire(args);
879
+ },
635
880
  recordIssuance: (i) => {
636
881
  refuseIfFenced();
637
882
  return recordIssuance(i);
@@ -696,11 +941,18 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
696
941
  }, principal, claim);
697
942
  },
698
943
  retireInteractiveLifecycle: async (args) => {
699
- refuseIfFenced();
700
- const owner = assertDerivedOwnerToken(args.owner);
701
- const actor = assertValidOwnerToken(args.actor);
702
- const lifecycleUid = assertLifecycleToken(args.lifecycleUid);
703
- const head = await readLifecycleHeadForOperation(barrierReg, owner, actor);
944
+ // Everything before the barrier only reads, so a refusal there retired nothing.
945
+ let owner, actor, lifecycleUid;
946
+ try {
947
+ refuseIfFenced();
948
+ owner = assertDerivedOwnerToken(args.owner);
949
+ actor = assertValidOwnerToken(args.actor);
950
+ lifecycleUid = assertLifecycleToken(args.lifecycleUid);
951
+ }
952
+ catch (e) {
953
+ throw notExecuted(e, "bad-request");
954
+ }
955
+ const head = await readLifecycleHeadForOperation(barrierReg, owner, actor).catch((e) => { throw notExecuted(e, "unavailable"); });
704
956
  if (head === undefined)
705
957
  return { retired: false, lifecycleUid, notStarted: true };
706
958
  if (head.mapping.lifecycleUid !== lifecycleUid) {
@@ -709,7 +961,7 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
709
961
  // An active/retiring foreign uid is different: the ledger and authority disagree, so fail.
710
962
  if (head.mapping.state === "retired")
711
963
  return { retired: false, lifecycleUid, notStarted: true };
712
- throw new EpEnvelopeError("conflict", `interactive lifecycle retirement for "${owner}/${actor}" names ${lifecycleUid}, but the authority head is ${head.mapping.state} at ${head.mapping.lifecycleUid}`);
964
+ throw new EpEnvelopeError("conflict", `interactive lifecycle retirement for "${owner}/${actor}" names ${lifecycleUid}, but the authority head is ${head.mapping.state} at ${head.mapping.lifecycleUid}`, undefined, "not-executed");
713
965
  }
714
966
  if (head.mapping.state === "retired")
715
967
  return { retired: true, lifecycleUid, alreadyRetired: true };
@@ -1157,31 +1409,48 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
1157
1409
  const t = r.target;
1158
1410
  const subscribe = t.subscribe ?? [];
1159
1411
  const { lifecycleUid, row } = await serializedForAlias(opts.dir, owner, t.actor, async () => {
1160
- // The token digest names one enrollment. A held row with this request's digest is that
1161
- // enrollment retried, so it answers the same uid; any other held row is a lifecycle that
1162
- // still runs, and taking its grant would strand it.
1163
- const held = findManagedActor(opts.dir, owner, t.actor);
1164
- if (held !== undefined && held.tokenHash !== t.tokenHash)
1165
- throw new EpEnvelopeError("conflict", `managed agent "${t.actor}" is enrolled at lifecycle ${held.lifecycleUid}; prepare that lifecycle's retirement before enrolling a successor`);
1166
- // A fresh uid is never one a retirement tombstoned, and only the host can make that promise.
1167
- const lifecycleUid = held?.lifecycleUid ?? mintLifecycleUid();
1168
- // The grant first: its envelope walk against the supervising actor refuses an over-wide
1169
- // request before any durable exists. A retry walks the held grant again: its spawner chain
1170
- // may have narrowed since the first answer, and provisioning would restore what it took away.
1171
- if (held !== undefined)
1172
- assertWithinSpawnerGrant(opts.dir, held, "spawn");
1173
- const row = held ?? grantManagedActor(opts.dir, {
1174
- owner,
1175
- actor: t.actor,
1176
- scope: t.capabilities ?? [],
1177
- allowSubscribe: t.allowSubscribe ?? [],
1178
- allowPublish: t.allowPublish ?? [],
1179
- ...(t.role !== undefined ? { role: t.role } : {}),
1180
- ...(t.label !== undefined ? { label: t.label } : {}),
1181
- parent: principalKey(owner, r.actor).key,
1182
- lifecycleUid,
1183
- tokenHash: t.tokenHash,
1412
+ // The custody this enrollment attests: the instance the authorized request names, checked
1413
+ // against its current registration above. Never a field of the participant's target.
1414
+ const custody = { kind: "remote-manager", instanceId: r.instanceId };
1415
+ // ONE synchronous transaction for the read, the decision and the write, so an
1416
+ // enrollment in another process (or a provider grant) cannot land between them.
1417
+ const decided = withManagedGrantTransaction(opts.dir, (tx) => {
1418
+ const current = tx.read(owner, t.actor);
1419
+ const held = current?.kind === "managed-agent" ? current : undefined;
1420
+ // The token digest names one enrollment. A held row with this request's digest is that
1421
+ // enrollment retried, so it answers the same uid; any other held row is a lifecycle that
1422
+ // still runs, and taking its grant would strand it.
1423
+ if (held !== undefined && held.tokenHash !== t.tokenHash)
1424
+ throw new EpEnvelopeError("conflict", `managed agent "${t.actor}" is enrolled at lifecycle ${held.lifecycleUid}; prepare that lifecycle's retirement before enrolling a successor`);
1425
+ // A retry is this enrollment only if the held row records this exact custody. A row with
1426
+ // none, or with another, is never upgraded or adopted here.
1427
+ if (held !== undefined && (held.custody === undefined || !sameManagedGrantCustody(held.custody, custody)))
1428
+ throw new EpEnvelopeError("conflict", `managed agent "${t.actor}" is held at lifecycle ${held.lifecycleUid} under another custody; it was not adopted by this enrollment`);
1429
+ // A retry walks the held grant again: its spawner chain may have narrowed since the
1430
+ // first answer, and provisioning would restore what it took away.
1431
+ if (held !== undefined) {
1432
+ assertWithinSpawnerGrant(opts.dir, held, "spawn");
1433
+ return { lifecycleUid: held.lifecycleUid, row: held, wrote: false };
1434
+ }
1435
+ // A fresh uid is never one a retirement tombstoned, and only the host can make that
1436
+ // promise. The grant's envelope walk refuses an over-wide request before any durable exists.
1437
+ const lifecycleUid = mintLifecycleUid();
1438
+ const row = tx.grantManaged({
1439
+ owner,
1440
+ actor: t.actor,
1441
+ scope: t.capabilities ?? [],
1442
+ allowSubscribe: t.allowSubscribe ?? [],
1443
+ allowPublish: t.allowPublish ?? [],
1444
+ ...(t.role !== undefined ? { role: t.role } : {}),
1445
+ ...(t.label !== undefined ? { label: t.label } : {}),
1446
+ parent: principalKey(owner, r.actor).key,
1447
+ lifecycleUid,
1448
+ tokenHash: t.tokenHash,
1449
+ custody,
1450
+ });
1451
+ return { lifecycleUid, row, wrote: true };
1184
1452
  });
1453
+ const { lifecycleUid, row } = decided;
1185
1454
  try {
1186
1455
  const identity = newIdentity();
1187
1456
  const provisioner = new CotalEndpoint({
@@ -1207,9 +1476,10 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
1207
1476
  catch (e) {
1208
1477
  // A retried enrollment's first answer may already have reached the participant, so its
1209
1478
  // grant stays. A grant this call wrote gave the participant no uid, so it can never be
1210
- // retired: it and any durable it got must go now.
1211
- if (held === undefined) {
1212
- revokeManagedActorAt(opts.dir, owner, t.actor, lifecycleUid);
1479
+ // retired: it and any durable it got must go now. The revoke removes only the row this
1480
+ // call wrote: same uid AND same custody, read under the grant lock.
1481
+ if (decided.wrote) {
1482
+ revokeManagedActorIf(opts.dir, owner, t.actor, { lifecycleUid, custody });
1213
1483
  await deprovisionManagedFootprint(owner, t.actor, lifecycleUid, []).catch((err) => opts.log(`auth-service: refused enrollment of ${owner}/${t.actor} left durables at ${lifecycleUid}: ${err instanceof Error ? err.message : String(err)}`));
1214
1484
  }
1215
1485
  throw e;
@@ -1249,8 +1519,17 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
1249
1519
  const row = findManagedActor(opts.dir, owner, actor);
1250
1520
  if (row?.lifecycleUid === lifecycleUid) {
1251
1521
  // Footprint before grant: while the row stands, a retry still knows which channels to purge.
1522
+ // The footprint work is async, so it runs outside the grant lock.
1252
1523
  await deprovisionManagedFootprint(owner, actor, lifecycleUid, row.allowSubscribe.filter(isConcreteChannel));
1253
- revokeManagedActorAt(opts.dir, owner, actor, lifecycleUid);
1524
+ // The decision and the write are one transaction: the row removed is the one read above
1525
+ // only if it still names this uid and the custody it carried then. A row written before
1526
+ // custody existed is removed by uid alone, as before.
1527
+ withManagedGrantTransaction(opts.dir, (tx) => {
1528
+ if (row.custody !== undefined)
1529
+ tx.revokeManagedIf(owner, actor, { lifecycleUid, custody: row.custody });
1530
+ else
1531
+ tx.revokeManagedAt(owner, actor, lifecycleUid);
1532
+ });
1254
1533
  }
1255
1534
  });
1256
1535
  return {
@@ -1301,6 +1580,34 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
1301
1580
  observeManagerGate: observeLegacyManagerGate,
1302
1581
  });
1303
1582
  },
1583
+ submitManagerRun: async ({ request, ...holder }) => {
1584
+ refuseIfFenced();
1585
+ const { owner } = holder;
1586
+ requireManagerAuthorityHolder(holder, String(request?.instanceId), 'keyed run submission needs scope "supervise"');
1587
+ return withIssuerSession({ servers: server, space, auth: issuerAuth(), tls: false }, session => handleRemoteRunSubmission({
1588
+ request, owner, space, accountPublicKey: dataAccount.pub, proofSecret: dataAccount.signingSeed, endpoint: "manager",
1589
+ observeManagerGate: observeLegacyManagerGate, issued: session.store, sourceIsLive: composedSourceIsLive(session),
1590
+ takeObserved: takeObservedRunRequest,
1591
+ registeredCommand: async (instanceId, registrationRevision, command) => {
1592
+ const registered = remoteManagerSurface(await registeredManagerCluster(owner, instanceId, { registrationRevision }))[command];
1593
+ if (!registered)
1594
+ throw new EpEnvelopeError("failed-precondition", "registered manager has no keyed contract");
1595
+ return registered;
1596
+ },
1597
+ withStore: async (runId, submission, work) => {
1598
+ const nc = await connect({ servers: server, ...standaloneConnectOpts({
1599
+ creds: await mintCreds(issuerAuth(), newIdentity(), "run-admitter", { runAdmitter: { endpoint: "manager", runId, submission }, expiresInSeconds: 60 }), tls: false,
1600
+ }), maxReconnectAttempts: 0 });
1601
+ onConnection?.(nc, `cotal:keyed-admitter:${space}`);
1602
+ try {
1603
+ return await work(await new Kvm(nc).open(admissionBucket(space)), await jetstreamManager(nc));
1604
+ }
1605
+ finally {
1606
+ await nc.drain().catch(() => nc.close());
1607
+ }
1608
+ },
1609
+ }));
1610
+ },
1304
1611
  admitManagerRun: async ({ request, ...holder }) => {
1305
1612
  refuseIfFenced();
1306
1613
  const { owner } = holder;
@@ -1333,6 +1640,46 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
1333
1640
  }
1334
1641
  });
1335
1642
  },
1643
+ cancelManagerRun: async ({ request, ...holder }) => {
1644
+ refuseIfFenced();
1645
+ const req = parseRemoteRunCancelRequest(request);
1646
+ requireManagerAuthorityHolder(holder, req.instanceId, 'manager run cancellation needs scope "supervise"');
1647
+ return withIssuerSession({ servers: server, space, auth: issuerAuth(), tls: false }, session => handleRemoteRunCancel({
1648
+ request: req, owner: holder.owner, space, endpoint: "manager", accountPublicKey: dataAccount.pub,
1649
+ proofSecret: dataAccount.signingSeed, observeManagerGate: observeLegacyManagerGate,
1650
+ observations: cancelObservations, flush: () => remoteIssuer.nc.flush(), assertServing: refuseIfFenced,
1651
+ issued: session.store, sourceIsLive: composedSourceIsLive(session),
1652
+ interactiveScope: async (caller, sources) => {
1653
+ if (sources.length !== 1 || sources[0].space !== space)
1654
+ return undefined;
1655
+ const source = parseActorLedgerSource(sources[0]);
1656
+ if (!source || source.owner !== caller.owner || source.actor !== caller.actor || source.lifecycleUid !== caller.uid)
1657
+ return undefined;
1658
+ const row = findActorUnified(opts.dir, caller.owner, caller.actor);
1659
+ return row?.kind === "interactive" && row.lifecycleUid === caller.uid ? [...row.scope] : undefined;
1660
+ },
1661
+ registeredCommand: async (instanceId, registrationRevision, command) => {
1662
+ const registered = remoteManagerSurface(await registeredManagerCluster(holder.owner, instanceId, { registrationRevision }))[command];
1663
+ if (!registered)
1664
+ throw new EpEnvelopeError("failed-precondition", "registered manager has no run-cancel contract");
1665
+ return registered;
1666
+ },
1667
+ readAdmission: runId => readCancellationAdmission(recordsJsm, space, "manager", runId),
1668
+ readRevocation: runId => readRunRevocation(recordsJsm, space, "manager", runId),
1669
+ withStore: async (runId, work) => {
1670
+ const nc = await connect({ servers: server, ...standaloneConnectOpts({
1671
+ creds: await mintCreds(issuerAuth(), newIdentity(), "run-admitter", { runAdmitter: { endpoint: "manager", runId }, expiresInSeconds: 60 }), tls: false,
1672
+ }), maxReconnectAttempts: 0 });
1673
+ onConnection?.(nc, `cotal:run-cancel:${space}`);
1674
+ try {
1675
+ return await work(await new Kvm(nc).open(admissionBucket(space)));
1676
+ }
1677
+ finally {
1678
+ await nc.drain().catch(() => nc.close());
1679
+ }
1680
+ },
1681
+ }));
1682
+ },
1336
1683
  revokeManagerRun: async ({ request, ...holder }) => {
1337
1684
  refuseIfFenced();
1338
1685
  const req = parseRemoteRunRevokeRequest(request);
@@ -1502,13 +1849,24 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
1502
1849
  return await serveIssuanceGateKv(authKv, space, { endpoint, instanceId }).observe();
1503
1850
  },
1504
1851
  fenced,
1852
+ ...(serviceHost !== undefined ? { serviceEndpointHost: serviceHost.handle } : {}),
1505
1853
  close: async () => {
1506
1854
  // Clean-close order (SPEC 13.13): the rail stops answering first, then scan-capable
1507
1855
  // clients down, then `held → released` (never released while either scanner can still
1508
1856
  // act), then the barrier that wrote it. An unreachable release keeps the barrier alive
1509
1857
  // for a later close retry; successful scanner teardown is not run a second time.
1510
1858
  closing = true;
1859
+ // Before the first await: no service host call enters once close has begun.
1860
+ serviceHost?.refuse();
1511
1861
  await stopPrune();
1862
+ await stopChatSweep();
1863
+ // Every service host call that entered returns, discovery and effects included, then every
1864
+ // effect connection that did not close is closed again, before discovery closes and before
1865
+ // the claim is released. A failure here rejects with the claim still held; close retries it.
1866
+ await serviceHost?.close();
1867
+ await closeEffects();
1868
+ await calloutWriter?.close();
1869
+ await discoveryClient?.close();
1512
1870
  const scannerFlight = closeScanners ??= (async () => {
1513
1871
  await authAdmin?.close();
1514
1872
  const results = await Promise.allSettled([reader.close(), recordsScanner.close(), scanner.close()]);
@@ -1609,19 +1967,8 @@ async function resumeOpenOperations(reg, evictPrincipal, retirement, log) {
1609
1967
  }
1610
1968
  }
1611
1969
  }
1612
- /** Run the auth service. Flags: `--space` (required), `--server` (broker URL, required), `--port`
1613
- * (loopback HTTP port; default ephemeral). All persisted material must already exist (the
1614
- * provider's `prepareServer` ran at `cotal up`) — a missing piece is a fail-loud config error
1615
- * naming the fix, never a silent partial service; this daemon LOADS the four secret kinds and
1616
- * never generates them.
1617
- *
1618
- * `store` is the hosted composition's injection point for the space's SECRET material (callout
1619
- * account, issuer keys, owner secret, service key projection) — when given, it is those kinds'
1620
- * ONLY source (no flag or path can point this daemon at other secret material; an absent key is
1621
- * the hard error below). Absent, the daemon composes the local workspace store over `.cotal/`.
1622
- * NON-SEAM state (the actor ledger, the IdP pin, the discovery file) still lives in the local
1623
- * state dir either way — hosting those is a later, separate seam. */
1624
- export async function runAuthService(args, store) {
1970
+ /** Pure native daemon input validation, shared with its explicit service launch plan. */
1971
+ export function authServiceConfiguration(args) {
1625
1972
  const v = args.values;
1626
1973
  const space = v.space;
1627
1974
  if (!space)
@@ -1652,9 +1999,27 @@ export async function runAuthService(args, store) {
1652
1999
  const badFace = publicFace && checkPublicFace(publicFace);
1653
2000
  if (badFace)
1654
2001
  throw new Error(badFace);
2002
+ return { space, server, port, publicFace };
2003
+ }
2004
+ /** Run the auth service. Flags: `--space` (required), `--server` (broker URL, required), `--port`
2005
+ * (loopback HTTP port; default ephemeral). All persisted material must already exist (the
2006
+ * provider's `prepareServer` ran at `cotal up`) — a missing piece is a fail-loud config error
2007
+ * naming the fix, never a silent partial service; this daemon LOADS the four secret kinds and
2008
+ * never generates them.
2009
+ *
2010
+ * `store` is the hosted composition's injection point for the space's SECRET material (callout
2011
+ * account, issuer keys, owner secret, service key projection) — when given, it is those kinds'
2012
+ * ONLY source (no flag or path can point this daemon at other secret material; an absent key is
2013
+ * the hard error below). Absent, the daemon composes the local workspace store over `.cotal/`.
2014
+ * NON-SEAM state (the actor ledger, the IdP pin, the discovery file) still lives in the local
2015
+ * state dir either way — hosting those is a later, separate seam. */
2016
+ export async function runAuthService(args, store, options = {}) {
2017
+ const interactiveManagerSelection = runtimeSelection(options);
2018
+ const lifecycle = "root" in options ? options : undefined;
2019
+ const { space, server, port, publicFace } = authServiceConfiguration(args);
1655
2020
  // The provider's space-scoped state dir for NON-SEAM material (ledger, IdP pin, discovery). The
1656
2021
  // layout fact is workspace-owned (userAuthStateDir); this daemon never touches `.cotal/auth/auth.json`.
1657
- const root = findCotalRoot();
2022
+ const root = lifecycle?.root ?? findCotalRoot();
1658
2023
  const dir = userAuthStateDir(root, space);
1659
2024
  const secrets = store ?? workspaceSecretStore(root);
1660
2025
  // Scrub any stale discovery file FIRST — a dead prior daemon's entry must never satisfy a
@@ -1686,25 +2051,44 @@ export async function runAuthService(args, store) {
1686
2051
  // The CLI composition's local manager is this workspace root's persisted instance, re-read per
1687
2052
  // selection exactly as before. A hosted context passes no local manager at all.
1688
2053
  localManager: () => loadManagerInstanceIdentity(root, space),
2054
+ ...(interactiveManagerSelection !== undefined ? { interactiveManagerSelection } : {}),
2055
+ ...(options.runCancelLimits !== undefined ? { runCancelLimits: options.runCancelLimits } : {}),
1689
2056
  });
1690
2057
  // All planes bound — NOW write the discovery file (its existence is the readiness signal).
1691
2058
  const { url, publicUrl, cap } = started.handle;
1692
2059
  saveAuthServiceInfo(dir, { url, pid: process.pid, cap, ...(publicUrl !== undefined ? { publicUrl } : {}) });
1693
- console.log(`✓ auth service up (space ${space}) - callout on ${server}, exchange/JWKS at ${url}${publicUrl !== undefined ? `, public exchange at ${publicUrl}` : ""}`);
2060
+ if (!lifecycle?.quiet)
2061
+ console.log(`✓ auth service up (space ${space}) - callout on ${server}, exchange/JWKS at ${url}${publicUrl !== undefined ? `, public exchange at ${publicUrl}` : ""}`);
1694
2062
  // The CLI wrapper, not the context, owns process signals and exit codes.
1695
- const stop = async () => {
2063
+ let stopping;
2064
+ const stop = () => stopping ??= (async () => {
1696
2065
  clearAuthServiceInfo(dir); // a dead service must not satisfy the next start's readiness poll
1697
2066
  try {
1698
2067
  await started.handle.close();
2068
+ await lifecycle?.onClosed();
1699
2069
  }
1700
2070
  catch (e) {
1701
- console.error(`✗ auth-service: ${e.message} - exiting`);
2071
+ console.error(lifecycle ? "✗ native auth-service shutdown did not confirm completion; custody was retained" : `✗ auth-service: ${e.message} - exiting`);
1702
2072
  process.exit(1);
1703
2073
  }
1704
2074
  process.exit(0);
1705
- };
2075
+ })();
1706
2076
  process.on("SIGINT", () => void stop());
1707
2077
  process.on("SIGTERM", () => void stop());
2078
+ if (lifecycle) {
2079
+ try {
2080
+ await lifecycle.onReady();
2081
+ }
2082
+ catch {
2083
+ clearAuthServiceInfo(dir);
2084
+ try {
2085
+ await started.handle.close();
2086
+ await lifecycle.onClosed();
2087
+ }
2088
+ catch { /* Unknown close keeps custody. */ }
2089
+ throw new Error("the native auth-service startup receipt was not published; no new launch was retried");
2090
+ }
2091
+ }
1708
2092
  // A dropped broker connection is fatal-loud, not a zombie: the supervising `up`/`down` lifecycle
1709
2093
  // owns restarts, and a callout that silently stopped answering would hang every user connect.
1710
2094
  // A FENCED plane is equally fatal (SPEC 13.13): its scanners are no longer whole, every authority
@@ -1713,13 +2097,14 @@ export async function runAuthService(args, store) {
1713
2097
  const ended = await started.ended;
1714
2098
  clearAuthServiceInfo(dir);
1715
2099
  if (ended.kind === "fenced") {
1716
- console.error(`✗ auth-service: ${ended.cause} - exiting`);
2100
+ console.error(lifecycle ? "✗ native auth-service authority was fenced; custody was retained" : `✗ auth-service: ${ended.cause} - exiting`);
1717
2101
  process.exit(1);
1718
2102
  }
1719
2103
  if (ended.cause !== undefined) {
1720
- console.error(`✗ auth-service: broker connection closed (${ended.cause}) - exiting`);
2104
+ console.error(lifecycle ? "✗ native auth-service broker connection closed; custody was retained" : `✗ auth-service: broker connection closed (${ended.cause}) - exiting`);
1721
2105
  process.exit(1);
1722
2106
  }
2107
+ await lifecycle?.onClosed();
1723
2108
  process.exit(0);
1724
2109
  }
1725
2110
  /** Start one account-scoped auth-service context. Every input is explicit: the state dir, the
@@ -1772,6 +2157,7 @@ export async function startAuthService(inputs) {
1772
2157
  ...(inputs.platformSupervisor !== undefined ? { platformSupervisor: inputs.platformSupervisor } : {}),
1773
2158
  ...(inputs.standingRenewableTtlSeconds !== undefined ? { standingRenewableTtlSeconds: inputs.standingRenewableTtlSeconds } : {}),
1774
2159
  ...(inputs.toolResultConsents !== undefined ? { toolResultConsents: inputs.toolResultConsents } : {}),
2160
+ ...(inputs.serviceEndpoints !== undefined ? { serviceEndpoints: inputs.serviceEndpoints } : {}),
1775
2161
  });
1776
2162
  return { ...started.handle, readiness: () => ({ ...started.status(), context: inputs.context }) };
1777
2163
  }
@@ -1811,7 +2197,10 @@ async function startAuthContext(o) {
1811
2197
  dataAccount: { pub: keys.dataAccount.pub, signingSeed: keys.dataAccount.signingSeed },
1812
2198
  log: (l) => console.error(l),
1813
2199
  localManager: o.localManager,
2200
+ ...(o.interactiveManagerSelection !== undefined ? { interactiveManagerSelection: o.interactiveManagerSelection } : {}),
2201
+ ...(o.runCancelLimits !== undefined ? { runCancelLimits: o.runCancelLimits } : {}),
1814
2202
  ...(o.standingRenewableTtlSeconds !== undefined ? { standingRenewableTtlSeconds: o.standingRenewableTtlSeconds } : {}),
2203
+ ...(o.serviceEndpoints !== undefined ? { serviceEndpoints: o.serviceEndpoints } : {}),
1815
2204
  }, onConnection);
1816
2205
  let nc;
1817
2206
  let http;
@@ -1836,7 +2225,9 @@ async function startAuthContext(o) {
1836
2225
  name: `cotal:auth-service:${space}`,
1837
2226
  });
1838
2227
  onConnection(nc, `cotal:auth-service:${space}`);
2228
+ const serviceCallout = plane.serviceEndpointHost !== undefined ? serviceHostCalloutOf(plane.serviceEndpointHost) : undefined;
1839
2229
  startAuthCallout(nc, {
2230
+ ...(serviceCallout !== undefined ? { serviceAuthority: serviceCallout } : {}),
1840
2231
  xkeySeed: callout.xkey.seed,
1841
2232
  authAccount: { pub: callout.account.pub, signingSeed: callout.account.signingSeed },
1842
2233
  dataAccount: { pub: keys.dataAccount.pub, signingSeed: keys.dataAccount.signingSeed },
@@ -1844,7 +2235,9 @@ async function startAuthContext(o) {
1844
2235
  token: { key: issuer.localKeySet(), issuer: issuer.issuer },
1845
2236
  authorizeActor: plane.authorizeConnect,
1846
2237
  recordIssuance: plane.recordIssuance,
1847
- permissionsFor: calloutPermissions(ledgerAclResolver(dir), plane.authorizeManagerCaller, plane.verifySession, plane.issueUserCaller),
2238
+ // A chat principal resolves from its closed row (the agent leaf, no role); every other
2239
+ // principal from the grant ledger.
2240
+ permissionsFor: calloutPermissions(chatAwareAclResolver(dir, ledgerAclResolver(dir)), plane.authorizeManagerCaller, plane.verifySession, plane.issueUserCaller),
1848
2241
  log: (l) => console.error(l),
1849
2242
  });
1850
2243
  // The subscription must be ON the broker before readiness is signaled — an `up` that recorded a
@@ -1859,6 +2252,7 @@ async function startAuthContext(o) {
1859
2252
  issuer,
1860
2253
  authorizeActor: ledgerAuthorizeGrant(dir),
1861
2254
  mintConnectCredential: plane.mintConnectCredential,
2255
+ ...(idp.dmReaderAudience !== undefined ? { dmReaderAudience: idp.dmReaderAudience } : {}),
1862
2256
  });
1863
2257
  const cap = randomBytes(32).toString("hex"); // per-start exchange capability (rotates with the daemon)
1864
2258
  // Set once the public face binds. An enrolled child's bearer exchanges there without the
@@ -1870,8 +2264,10 @@ async function startAuthContext(o) {
1870
2264
  space, accountPublicKey: keys.dataAccount.pub, dir, store: secrets, idp: bridgeIdp, ownerSecret,
1871
2265
  signer: o.toolResultConsents, record: plane.recordToolResultConsent,
1872
2266
  });
2267
+ const serviceEngine = plane.serviceEndpointHost !== undefined ? serviceHostEngineOf(plane.serviceEndpointHost) : undefined;
1873
2268
  const ctx = {
1874
2269
  ...(issueToolResultConsent ? { issueToolResultConsent } : {}),
2270
+ ...(serviceEngine !== undefined ? { serviceAuthority: { space, accountPublicKey: keys.dataAccount.pub, engine: serviceEngine, material: issuer } } : {}),
1875
2271
  issuer,
1876
2272
  bridge,
1877
2273
  bridgeIdp,
@@ -1891,7 +2287,9 @@ async function startAuthContext(o) {
1891
2287
  scanManagerGoalIndex: plane.scanManagerGoalIndex,
1892
2288
  authorizeManagerAdmin: plane.authorizeManagerAdmin,
1893
2289
  admitManagerRun: plane.admitManagerRun,
2290
+ submitManagerRun: plane.submitManagerRun,
1894
2291
  revokeManagerRun: plane.revokeManagerRun,
2292
+ cancelManagerRun: plane.cancelManagerRun,
1895
2293
  issueManagerRunAttempt: plane.issueManagerRunAttempt,
1896
2294
  secrets,
1897
2295
  retireInteractiveLifecycle: plane.retireInteractiveLifecycle,
@@ -1905,6 +2303,9 @@ async function startAuthContext(o) {
1905
2303
  selectManagerInstance: plane.selectManagerInstance,
1906
2304
  listManagerInstances: plane.listManagerInstances,
1907
2305
  verifySession: plane.verifySession,
2306
+ openChatSession: plane.openChatSession,
2307
+ chatSessionBearer: plane.chatSessionBearer,
2308
+ retireChatSession: plane.retireChatSession,
1908
2309
  };
1909
2310
  const loopback = createServer((req, res) => void handle(req, res, ctx));
1910
2311
  http = loopback;
@@ -2073,6 +2474,7 @@ async function startAuthContext(o) {
2073
2474
  cap,
2074
2475
  closed: owned.closed,
2075
2476
  connections: () => owned.connections(),
2477
+ ...(plane.serviceEndpointHost !== undefined ? { serviceEndpointHost: hostedServiceEndpoints(plane.serviceEndpointHost, refuseUnlessReady) } : {}),
2076
2478
  ...(o.platformSupervisor !== undefined ? {
2077
2479
  platformSupervisorAuthority: async (owner) => {
2078
2480
  refuseUnlessReady();
@@ -2103,6 +2505,7 @@ async function startAuthContext(o) {
2103
2505
  case "manager-goal-index-scan": return plane.scanManagerGoalIndex({ ...holder, request });
2104
2506
  case "manager-admin-authorization": return plane.authorizeManagerAdmin({ ...holder, request });
2105
2507
  case "manager-run-admission": return plane.admitManagerRun({ ...holder, request });
2508
+ case "manager-run-submission": return plane.submitManagerRun({ ...holder, request });
2106
2509
  case "manager-run-attempt": return plane.issueManagerRunAttempt({ ...holder, request });
2107
2510
  case "manager-retained-agent-validation": return validateRetainedForHolder(holder, request);
2108
2511
  }
@@ -2199,6 +2602,16 @@ export async function dispatchManagerAuthorityRequest(ctx, owner, body) {
2199
2602
  return ctx.authorizeManagerAdmin({ owner, scope: row.scope ?? [], request: body.request });
2200
2603
  if (request.kind === "manager-run-admission")
2201
2604
  return ctx.admitManagerRun({ owner, scope: row.scope ?? [], request: body.request });
2605
+ if (request.kind === "manager-run-submission") {
2606
+ if (!ctx.submitManagerRun)
2607
+ throw new EpEnvelopeError("unimplemented", "issuing host has no keyed submission handler");
2608
+ return ctx.submitManagerRun({ owner, scope: row.scope ?? [], request: body.request });
2609
+ }
2610
+ if (request.kind === "manager-run-cancel") {
2611
+ if (!ctx.cancelManagerRun)
2612
+ throw new EpEnvelopeError("unimplemented", "this issuing host has no run-cancel handler");
2613
+ return ctx.cancelManagerRun({ owner, scope: row.scope ?? [], request: body.request });
2614
+ }
2202
2615
  if (request.kind === "manager-run-revoke") {
2203
2616
  if (ctx.revokeManagerRun === undefined)
2204
2617
  throw new EpEnvelopeError("unimplemented", "this issuing host has no run revoke handler");
@@ -2283,6 +2696,13 @@ class RequestBodyError extends Error {
2283
2696
  this.status = status;
2284
2697
  }
2285
2698
  }
2699
+ /** `e`, raised before any write, as a refusal that states so (SPEC 13.3). An envelope error keeps
2700
+ * its code; any other error takes `code`. */
2701
+ function notExecuted(e, code) {
2702
+ return e instanceof EpEnvelopeError
2703
+ ? new EpEnvelopeError(e.code, e.message, e.details, "not-executed")
2704
+ : new EpEnvelopeError(code, e instanceof Error ? e.message : String(e), undefined, "not-executed");
2705
+ }
2286
2706
  function sendRequestError(res, e) {
2287
2707
  if (e instanceof RequestBodyError)
2288
2708
  return send(res, e.status, { error: e.message });
@@ -2306,18 +2726,22 @@ const ROUTES = new Map([
2306
2726
  ["/exchange", (req, res, ctx) => handleExchange(req, res, ctx, LOOPBACK_POLICY)],
2307
2727
  ["/manager-service-authority", (req, res, ctx) => handleManagerServiceAuthority(req, res, ctx, LOOPBACK_POLICY)],
2308
2728
  ["/manager-instances", (req, res, ctx) => handleManagerInstances(req, res, ctx, LOOPBACK_POLICY)],
2729
+ [SERVICE_ENDPOINT_AUTHORITY_PATH, (req, res, ctx) => handleServiceEndpointAuthority(req, res, ctx, LOOPBACK_POLICY)],
2309
2730
  [INTERACTIVE_RETIRE_PATH, handleInteractiveLifecycleRetirement],
2310
2731
  [MANAGED_RETIRE_PATH, handleManagedLifecycleRetirement],
2311
2732
  [VERIFY_ENROLLMENT_PATH, handleVerifyManagedAgentEnrollment],
2312
2733
  [TOOL_RESULT_HUMAN_PROOF_PATH, handleToolResultHumanProof],
2313
2734
  [TOOL_RESULT_CONSENT_PATH, handleToolResultConsent],
2735
+ [CHAT_SESSION_PATHS.open, (req, res, ctx) => handleChatSession(req, res, ctx, LOOPBACK_POLICY, "open")],
2736
+ [CHAT_SESSION_PATHS.bearer, (req, res, ctx) => handleChatSession(req, res, ctx, LOOPBACK_POLICY, "bearer")],
2737
+ [CHAT_SESSION_PATHS.retire, (req, res, ctx) => handleChatSession(req, res, ctx, LOOPBACK_POLICY, "retire")],
2314
2738
  ]);
2315
2739
  /** Route one HTTP request against the loopback route table. Errors are JSON `{ error }`. */
2316
2740
  async function handle(req, res, ctx) {
2317
2741
  try {
2318
2742
  const route = ROUTES.get(req.url ?? "");
2319
2743
  if (!route)
2320
- return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /manager-instances, /manager-service-authority/verify-enrollment, /interactive-lifecycle/retire, /managed-lifecycle/retire" });
2744
+ return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /manager-instances, /manager-service-authority/verify-enrollment, /interactive-lifecycle/retire, /managed-lifecycle/retire, /chat-session/open, /chat-session/bearer, /chat-session/retire" });
2321
2745
  await route(req, res, ctx);
2322
2746
  }
2323
2747
  catch (e) {
@@ -2325,34 +2749,50 @@ async function handle(req, res, ctx) {
2325
2749
  }
2326
2750
  }
2327
2751
  const INTERACTIVE_RETIRE_KEYS = new Set(["owner", "actor", "lifecycleUid"]);
2752
+ /** The interactive retirement door. Every refusal before the barrier starts retired nothing and
2753
+ * answers `outcome: "not-executed"` (SPEC 13.3), with the plane's native `code` when the plane
2754
+ * raised it. A failure inside the barrier answers 400 with no outcome, which reads as unknown. */
2328
2755
  async function handleInteractiveLifecycleRetirement(req, res, ctx) {
2756
+ const before = { outcome: "not-executed" };
2329
2757
  if (req.method !== "POST")
2330
- return send(res, 405, { error: "POST only" });
2758
+ return send(res, 405, { error: "POST only", ...before });
2331
2759
  if (req.headers.origin !== undefined)
2332
- return send(res, 403, { error: "browser-origin requests are not served here" });
2760
+ return send(res, 403, { error: "browser-origin requests are not served here", ...before });
2333
2761
  if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
2334
- return send(res, 415, { error: "content-type must be application/json" });
2762
+ return send(res, 415, { error: "content-type must be application/json", ...before });
2335
2763
  if (req.headers.authorization !== `Bearer ${ctx.cap}`)
2336
- return send(res, 401, { error: "missing/invalid exchange capability - interactive lifecycle retirement is a loopback operator action" });
2764
+ return send(res, 401, { error: "missing/invalid exchange capability - interactive lifecycle retirement is a loopback operator action", ...before });
2337
2765
  const body = await readJsonBody(req);
2338
2766
  if (body === null || typeof body !== "object" || Array.isArray(body))
2339
- return send(res, 400, { error: "interactive lifecycle retirement needs { owner, actor, lifecycleUid }" });
2767
+ return send(res, 400, { error: "interactive lifecycle retirement needs { owner, actor, lifecycleUid }", ...before });
2340
2768
  for (const key of Object.keys(body))
2341
2769
  if (!INTERACTIVE_RETIRE_KEYS.has(key))
2342
- return send(res, 400, { error: `interactive lifecycle retirement carries the unknown field "${key}"` });
2770
+ return send(res, 400, { error: `interactive lifecycle retirement carries the unknown field "${key}"`, ...before });
2343
2771
  const raw = body;
2344
2772
  if (typeof raw.owner !== "string" || typeof raw.actor !== "string" || typeof raw.lifecycleUid !== "string")
2345
- return send(res, 400, { error: "interactive lifecycle retirement needs string { owner, actor, lifecycleUid }" });
2346
- const owner = assertDerivedOwnerToken(raw.owner);
2347
- const actor = assertValidOwnerToken(raw.actor);
2348
- const lifecycleUid = assertLifecycleToken(raw.lifecycleUid);
2773
+ return send(res, 400, { error: "interactive lifecycle retirement needs string { owner, actor, lifecycleUid }", ...before });
2774
+ let owner, actor, lifecycleUid;
2775
+ try {
2776
+ owner = assertDerivedOwnerToken(raw.owner);
2777
+ actor = assertValidOwnerToken(raw.actor);
2778
+ lifecycleUid = assertLifecycleToken(raw.lifecycleUid);
2779
+ }
2780
+ catch (e) {
2781
+ return send(res, 400, { error: e instanceof Error ? e.message : String(e), ...before });
2782
+ }
2349
2783
  const row = findInteractiveActor(ctx.dir, owner, actor);
2350
2784
  if (!row)
2351
- return send(res, 404, { error: `interactive actor "${owner}/${actor}" is not granted` });
2785
+ return send(res, 404, { error: `interactive actor "${owner}/${actor}" is not granted`, ...before });
2352
2786
  if (row.lifecycleUid !== lifecycleUid)
2353
- return send(res, 409, { error: `interactive actor "${owner}/${actor}" is current at lifecycle ${row.lifecycleUid ?? "<missing>"}, not ${lifecycleUid}` });
2354
- const result = await ctx.retireInteractiveLifecycle({ owner, actor, lifecycleUid });
2355
- return send(res, 200, result);
2787
+ return send(res, 409, { error: `interactive actor "${owner}/${actor}" is current at lifecycle ${row.lifecycleUid ?? "<missing>"}, not ${lifecycleUid}`, ...before });
2788
+ try {
2789
+ return send(res, 200, await ctx.retireInteractiveLifecycle({ owner, actor, lifecycleUid }));
2790
+ }
2791
+ catch (e) {
2792
+ if (e instanceof EpEnvelopeError && e.outcome === "not-executed")
2793
+ return send(res, REFUSAL_STATUS[e.code] ?? 500, { error: e.message, code: e.code, outcome: e.outcome });
2794
+ throw e;
2795
+ }
2356
2796
  }
2357
2797
  /** The host's terminal step for a managed lifecycle whose remote manager is gone: the same request
2358
2798
  * guards as the interactive door, but it requires the managed grant to be REVOKED first (the
@@ -2487,15 +2927,15 @@ async function handleVerifyManagedAgentEnrollment(req, res, ctx) {
2487
2927
  }
2488
2928
  /** Refuse a missing/invalid loopback capability, audited and counted in its own window (see
2489
2929
  * BAD_CAP_PER_MIN): 401 with `reason` until the window is full, then 429. */
2490
- function refuseCapability(res, ctx, reason) {
2930
+ function refuseCapability(res, ctx, reason, extra = {}) {
2491
2931
  const now = Date.now();
2492
2932
  while (ctx.badCaps.length && now - ctx.badCaps[0] > 60_000)
2493
2933
  ctx.badCaps.shift();
2494
2934
  ctx.badCaps.push(now);
2495
2935
  console.error("auth-service: rejected an exchange with a missing/invalid capability");
2496
2936
  if (ctx.badCaps.length > BAD_CAP_PER_MIN)
2497
- return send(res, 429, { error: "too many invalid-capability attempts - wait a minute and retry" });
2498
- send(res, 401, { error: reason });
2937
+ return send(res, 429, { error: "too many invalid-capability attempts - wait a minute and retry", ...extra });
2938
+ send(res, 401, { error: reason, ...extra });
2499
2939
  }
2500
2940
  /** The exchange body, shared by every face; `policy` says how this face proves and attributes the
2501
2941
  * caller. Behavior on the loopback face is unchanged from the pre-route-table handler. */
@@ -2646,6 +3086,93 @@ async function handleExchange(req, res, ctx, policy) {
2646
3086
  return send(res, 401, { error: reason });
2647
3087
  }
2648
3088
  }
3089
+ /** Each chat-session route's CLOSED body: an unknown or missing field refuses before any read. */
3090
+ const CHAT_SESSION_FIELDS = {
3091
+ open: { required: ["idpToken", "holderHash", "read", "post", "ttlSec"], optional: ["label"] },
3092
+ bearer: { required: ["idpToken", "actor", "holderProof"], optional: [] },
3093
+ retire: { required: ["idpToken", "actor", "lifecycleUid", "holderProof"], optional: [] },
3094
+ };
3095
+ /** HTTP status for a door refusal's native code; the body carries the code itself. */
3096
+ const REFUSAL_STATUS = {
3097
+ "bad-request": 400, unauthenticated: 401, "permission-denied": 403, "not-found": 404, conflict: 409,
3098
+ "failed-precondition": 412, "resource-exhausted": 429, unimplemented: 501, unavailable: 503,
3099
+ };
3100
+ /**
3101
+ * The chat-session routes, on both faces. The owner is derived from a fresh IdP proof exactly as
3102
+ * `/exchange` derives it; the body never names one. `open` admits, `bearer` exchanges the holder
3103
+ * proof for one short bearer, `retire` retires. Every refusal answers `{ error, code, outcome }`:
3104
+ * `not-executed` when nothing was written, `executed` when this request wrote before refusing, and
3105
+ * `unknown` when a write may have landed (a retry with the SAME holder resolves it). The holder proof
3106
+ * and the bearer never reach a log line.
3107
+ */
3108
+ async function handleChatSession(req, res, ctx, policy, op) {
3109
+ // Every answer before the dispatch is refused with nothing read or written.
3110
+ const before = { outcome: "not-executed" };
3111
+ if (req.method !== "POST")
3112
+ return send(res, 405, { error: "POST only", code: "bad-request", ...before });
3113
+ if (req.headers.origin !== undefined)
3114
+ return send(res, 403, { error: "browser-origin requests are not served here", code: "permission-denied", ...before });
3115
+ if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
3116
+ return send(res, 415, { error: "content-type must be application/json", code: "bad-request", ...before });
3117
+ if (policy.requireCapability && (req.headers.authorization ?? "") !== `Bearer ${ctx.cap}`)
3118
+ return refuseCapability(res, ctx, "missing/invalid exchange capability - read it from the space's auth-service.json", { code: "unauthenticated", ...before });
3119
+ const peer = policy.peerKey(req);
3120
+ const peerThrottled = policy.throttled(ctx, peer);
3121
+ const body = await readJsonBody(req);
3122
+ const malformed = (why) => send(res, 400, { error: `chat-session ${op}: ${why}`, code: "bad-request", ...before });
3123
+ if (body === null || typeof body !== "object" || Array.isArray(body))
3124
+ return malformed("the body must be a JSON object");
3125
+ const b = body;
3126
+ const fields = CHAT_SESSION_FIELDS[op];
3127
+ const extra = Object.keys(b).find((k) => !fields.required.includes(k) && !fields.optional.includes(k));
3128
+ if (extra !== undefined)
3129
+ return malformed(`unknown field "${extra}"`);
3130
+ const missing = fields.required.find((k) => !Object.hasOwn(b, k));
3131
+ if (missing !== undefined)
3132
+ return malformed(`missing field "${missing}"`);
3133
+ if (typeof b.idpToken !== "string" || b.idpToken === "")
3134
+ return malformed("idpToken must be a non-empty string");
3135
+ if (op === "open" && (typeof b.holderHash !== "string" || !Array.isArray(b.read) || !Array.isArray(b.post) || typeof b.ttlSec !== "number" || (b.label !== undefined && typeof b.label !== "string")))
3136
+ return malformed("open takes { idpToken, holderHash: string, read: string[], post: string[], ttlSec: number, label?: string }");
3137
+ if (op !== "open" && (typeof b.actor !== "string" || typeof b.holderProof !== "string" || (op === "retire" && typeof b.lifecycleUid !== "string")))
3138
+ return malformed(op === "bearer" ? "bearer takes { idpToken, actor: string, holderProof: string }" : "retire takes { idpToken, actor: string, lifecycleUid: string, holderProof: string }");
3139
+ // A refusal keeps the effect outcome its source stated; one that states none is unknown.
3140
+ const refuse = (status, message, code, outcome = "unknown") => {
3141
+ policy.recordFailure(ctx, peer);
3142
+ console.error(`auth-service: refused a chat-session ${op}: ${message}`);
3143
+ if (peerThrottled)
3144
+ return send(res, 429, { error: "too many refused exchanges - wait a minute and retry", code: "resource-exhausted", outcome });
3145
+ return send(res, status, { error: message, code, outcome });
3146
+ };
3147
+ let owner;
3148
+ let idpExp;
3149
+ try {
3150
+ const verified = await verifyIdpToken(b.idpToken, ctx.bridgeIdp);
3151
+ owner = deriveOwnerForIdpSubject(ctx.ownerSecret, ctx.bridgeIdp.issuer, verified.sub);
3152
+ idpExp = verified.exp;
3153
+ }
3154
+ catch (e) {
3155
+ return refuse(401, e instanceof Error ? e.message : String(e), "unauthenticated", "not-executed");
3156
+ }
3157
+ try {
3158
+ if (op === "open")
3159
+ return send(res, 200, await ctx.openChatSession({
3160
+ owner, holderHash: b.holderHash, read: b.read, post: b.post, ttlSec: b.ttlSec,
3161
+ ...(typeof b.label === "string" ? { label: b.label } : {}),
3162
+ }));
3163
+ if (op === "bearer") {
3164
+ const minted = await ctx.chatSessionBearer({ owner, actor: b.actor, holderProof: b.holderProof, idpExp, issuer: ctx.issuer });
3165
+ return send(res, 200, { token: minted.token, owner, exp: minted.exp });
3166
+ }
3167
+ return send(res, 200, await ctx.retireChatSession({ owner, actor: b.actor, lifecycleUid: b.lifecycleUid, holderProof: b.holderProof }));
3168
+ }
3169
+ catch (e) {
3170
+ if (e instanceof EpEnvelopeError)
3171
+ return refuse(REFUSAL_STATUS[e.code] ?? 500, e.message, e.code, e.outcome ?? "unknown");
3172
+ // Anything else came from a store, broker or file read partway through: its effect is unknown.
3173
+ return refuse(503, e instanceof Error ? e.message : String(e), "unavailable", "unknown");
3174
+ }
3175
+ }
2649
3176
  /** The signed-in person's own Manager instances, read-only, on both faces. The proof is the IdP JWT
2650
3177
  * the human exchange verifies, and the owner is derived from it the same way, so a caller lists only
2651
3178
  * its own owner's gates. A browser-origin request is refused as on every exchange route; an
@@ -2685,6 +3212,80 @@ async function handleManagerInstances(req, res, ctx, policy) {
2685
3212
  return send(res, 503, { error: `the manager instance list is unavailable: ${e instanceof Error ? e.message : String(e)}` });
2686
3213
  }
2687
3214
  }
3215
+ /** A service renewal or access proof, verified against this context's own signing keys: the
3216
+ * signed witness it binds. A request that carries a witness must name the proof's; a status query
3217
+ * carries none, so its proof's own witness is the expected one. Current native state is the
3218
+ * engine's check, never the proof's. */
3219
+ async function verifyServiceProof(proof, request, issuer, space) {
3220
+ const key = issuer.localKeySet();
3221
+ if (proof.kind === "service-renewal") {
3222
+ const expected = "witness" in request ? request.witness : parseServiceRegistrationWitness(decodeJwt(proof.token).witness);
3223
+ return (await validateServiceRenewalProof(proof.token, { issuer: issuer.issuer, key, witness: expected })).witness;
3224
+ }
3225
+ const token = await validateUserToken(proof.token, { issuer: issuer.issuer, audience: space, key });
3226
+ if (token.act.view !== "service-endpoint" || token.act.service?.duty !== "serve")
3227
+ throw new Error("the access proof is not a service bearer");
3228
+ const witness = parseServiceRegistrationWitness(token.act.service.witness);
3229
+ if (token.owner !== witness.principal.owner || token.act.owner !== witness.principal.owner || token.act.actor !== witness.principal.actor
3230
+ || token.act.lifecycleUid !== witness.principal.lifecycleUid || token.credentialId !== witness.rootCredentialId)
3231
+ throw new Error("the access proof does not bind its witness");
3232
+ if ("witness" in request && serviceWitnessDigest(request.witness) !== serviceWitnessDigest(witness))
3233
+ throw new Error("the access proof binds another witness");
3234
+ return witness;
3235
+ }
3236
+ /** The public service endpoint authority route, on both faces and only with `serviceEndpoints`: GET
3237
+ * reads the account info, POST takes one `{request, proof}` envelope. The proof is the credential on
3238
+ * either face; the loopback capability is never read or accepted here. An IdP proof verifies against
3239
+ * the pinned issuer and exact audience, and its subject derives the owner. A service renewal or
3240
+ * access proof verifies against this context's own issuer and binds a witness, never an owner's
3241
+ * authority. A refused proof counts against the face's refusal budget; a valid proof does not,
3242
+ * whatever the answer. */
3243
+ async function handleServiceEndpointAuthority(req, res, ctx, policy) {
3244
+ const door = ctx.serviceAuthority;
3245
+ if (door === undefined)
3246
+ return send(res, 404, { error: "service endpoint authority is not enabled for this space" });
3247
+ if (req.headers.origin !== undefined)
3248
+ return send(res, 403, { error: "browser-origin requests are not served here" });
3249
+ const noStore = { "cache-control": "no-store" };
3250
+ if (req.method === "GET")
3251
+ return send(res, 200, serviceAuthorityInfo(door), noStore);
3252
+ if (req.method !== "POST")
3253
+ return send(res, 405, { error: "GET or POST only" });
3254
+ if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
3255
+ return send(res, 415, { error: "content-type must be application/json" });
3256
+ const peer = policy.peerKey(req);
3257
+ const peerThrottled = policy.throttled(ctx, peer);
3258
+ let envelope;
3259
+ try {
3260
+ envelope = parseServiceAuthorityEnvelope(await readJsonBody(req, SERVICE_AUTHORITY_MAX_BYTES));
3261
+ }
3262
+ catch (e) {
3263
+ return sendRequestError(res, e);
3264
+ }
3265
+ const { request, proof } = envelope;
3266
+ const answer = (r) => send(res, r.status, r.body, noStore);
3267
+ if (request.space !== door.space || request.accountPublicKey !== door.accountPublicKey)
3268
+ return answer(serviceAuthorityDenied(request, "account-mismatch"));
3269
+ let caller;
3270
+ try {
3271
+ caller = proof.kind === "idp"
3272
+ ? { kind: "idp", owner: deriveOwnerForIdpSubject(ctx.ownerSecret, ctx.bridgeIdp.issuer, (await verifyIdpToken(proof.token, ctx.bridgeIdp)).sub) }
3273
+ : { kind: "service", witness: await verifyServiceProof(proof, request, ctx.issuer, door.space) };
3274
+ }
3275
+ catch (e) {
3276
+ policy.recordFailure(ctx, peer);
3277
+ console.error(`auth-service: refused a service endpoint authority proof: ${e instanceof Error ? e.message : String(e)}`);
3278
+ return answer(serviceAuthorityDenied(request, peerThrottled ? "rate-limited" : "unauthenticated"));
3279
+ }
3280
+ try {
3281
+ return answer(await answerServiceAuthority(door, request, caller));
3282
+ }
3283
+ catch (e) {
3284
+ // After the proof, a register may already be bound: an unexpected failure is an unknown effect.
3285
+ console.error(`auth-service: service endpoint authority ${request.operation} failed: ${e instanceof Error ? e.message : String(e)}`);
3286
+ return send(res, 503, { error: "service endpoint authority did not complete; the request may be bound, so retain its request identity" }, noStore);
3287
+ }
3288
+ }
2688
3289
  /** The typed manager authority exchange, served on both faces. The face's policy decides whether the
2689
3290
  * loopback capability is demanded; on the public face the verified IdP JWT is the proof, and the
2690
3291
  * dispatcher reads the caller's scope from the ledger either way. */
@@ -2891,6 +3492,10 @@ function makePublicHandler(ctx, policy, bundle) {
2891
3492
  ["/exchange", (req, res, c) => handleExchange(req, res, c, policy)],
2892
3493
  ["/manager-service-authority", (req, res, c) => handleManagerServiceAuthority(req, res, c, policy)],
2893
3494
  ["/manager-instances", (req, res, c) => handleManagerInstances(req, res, c, policy)],
3495
+ [SERVICE_ENDPOINT_AUTHORITY_PATH, (req, res, c) => handleServiceEndpointAuthority(req, res, c, policy)],
3496
+ [CHAT_SESSION_PATHS.open, (req, res, c) => handleChatSession(req, res, c, policy, "open")],
3497
+ [CHAT_SESSION_PATHS.bearer, (req, res, c) => handleChatSession(req, res, c, policy, "bearer")],
3498
+ [CHAT_SESSION_PATHS.retire, (req, res, c) => handleChatSession(req, res, c, policy, "retire")],
2894
3499
  [
2895
3500
  "/.well-known/cotal-mesh",
2896
3501
  (req, res) => {
@@ -2923,7 +3528,7 @@ function makePublicHandler(ctx, policy, bundle) {
2923
3528
  try {
2924
3529
  const route = routes.get(req.url ?? "");
2925
3530
  if (!route)
2926
- return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /manager-instances, /.well-known/cotal-mesh" });
3531
+ return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /manager-instances, /chat-session/open, /chat-session/bearer, /chat-session/retire, /.well-known/cotal-mesh" });
2927
3532
  await route(req, res, ctx);
2928
3533
  }
2929
3534
  catch (e) {
@@ -2934,16 +3539,16 @@ function makePublicHandler(ctx, policy, bundle) {
2934
3539
  };
2935
3540
  }
2936
3541
  /** Read + parse a small JSON body, bounded — the exchange payload is an IdP JWT plus an actor name;
2937
- * 64 KB clears any sane JWT while keeping the loopback surface un-floodable. */
2938
- function readJsonBody(req) {
2939
- const MAX = 64 * 1024;
3542
+ * 64 KB clears any sane JWT while keeping the loopback surface un-floodable. A route with a larger
3543
+ * protocol envelope passes its own bound. */
3544
+ function readJsonBody(req, max = 64 * 1024) {
2940
3545
  return new Promise((resolve, reject) => {
2941
3546
  let size = 0;
2942
3547
  let tooLarge = false;
2943
3548
  const chunks = [];
2944
3549
  req.on("data", (c) => {
2945
3550
  size += c.length;
2946
- if (size > MAX) {
3551
+ if (size > max) {
2947
3552
  // Stop retaining bytes immediately, but keep draining the request so the client receives
2948
3553
  // the explicit 413 JSON response. Destroying the socket hid the server's decision behind
2949
3554
  // an undici transport error and made the size-bound smoke unable to prove this guard.
@@ -2956,7 +3561,7 @@ function readJsonBody(req) {
2956
3561
  });
2957
3562
  req.on("end", () => {
2958
3563
  if (tooLarge)
2959
- return reject(new RequestBodyError(413, "request body too large (maximum 65536 bytes)"));
3564
+ return reject(new RequestBodyError(413, `request body too large (maximum ${max} bytes)`));
2960
3565
  try {
2961
3566
  resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
2962
3567
  }