@cotal-ai/auth 0.90.0 → 0.91.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 (80) hide show
  1. package/dist/authority-client.d.ts.map +1 -1
  2. package/dist/authority-client.js +4 -1
  3. package/dist/authority-client.js.map +1 -1
  4. package/dist/chat-parent-pin.d.ts +43 -0
  5. package/dist/chat-parent-pin.d.ts.map +1 -0
  6. package/dist/chat-parent-pin.js +115 -0
  7. package/dist/chat-parent-pin.js.map +1 -0
  8. package/dist/chat-session-authority.d.ts +120 -0
  9. package/dist/chat-session-authority.d.ts.map +1 -0
  10. package/dist/chat-session-authority.js +924 -0
  11. package/dist/chat-session-authority.js.map +1 -0
  12. package/dist/chat-session-row.d.ts +117 -0
  13. package/dist/chat-session-row.d.ts.map +1 -0
  14. package/dist/chat-session-row.js +358 -0
  15. package/dist/chat-session-row.js.map +1 -0
  16. package/dist/chat-session-store.d.ts +126 -0
  17. package/dist/chat-session-store.d.ts.map +1 -0
  18. package/dist/chat-session-store.js +392 -0
  19. package/dist/chat-session-store.js.map +1 -0
  20. package/dist/commands.d.ts +3 -1
  21. package/dist/commands.d.ts.map +1 -1
  22. package/dist/commands.js +497 -122
  23. package/dist/commands.js.map +1 -1
  24. package/dist/continuity.js +1 -1
  25. package/dist/continuity.js.map +1 -1
  26. package/dist/daemon-flags.d.ts +42 -0
  27. package/dist/daemon-flags.d.ts.map +1 -0
  28. package/dist/daemon-flags.js +12 -0
  29. package/dist/daemon-flags.js.map +1 -0
  30. package/dist/handle-inspect.d.ts +101 -0
  31. package/dist/handle-inspect.d.ts.map +1 -0
  32. package/dist/handle-inspect.js +50 -0
  33. package/dist/handle-inspect.js.map +1 -0
  34. package/dist/handle-verification.d.ts +75 -0
  35. package/dist/handle-verification.d.ts.map +1 -0
  36. package/dist/handle-verification.js +135 -0
  37. package/dist/handle-verification.js.map +1 -0
  38. package/dist/idp.d.ts +23 -1
  39. package/dist/idp.d.ts.map +1 -1
  40. package/dist/idp.js +54 -8
  41. package/dist/idp.js.map +1 -1
  42. package/dist/index.d.ts +3 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +3 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/ledger.d.ts +58 -5
  47. package/dist/ledger.d.ts.map +1 -1
  48. package/dist/ledger.js +126 -6
  49. package/dist/ledger.js.map +1 -1
  50. package/dist/lifecycle-registry.d.ts +1 -70
  51. package/dist/lifecycle-registry.d.ts.map +1 -1
  52. package/dist/lifecycle-registry.js +4 -35
  53. package/dist/lifecycle-registry.js.map +1 -1
  54. package/dist/login.d.ts +9 -0
  55. package/dist/login.d.ts.map +1 -1
  56. package/dist/login.js +53 -8
  57. package/dist/login.js.map +1 -1
  58. package/dist/permissions.js +4 -0
  59. package/dist/permissions.js.map +1 -1
  60. package/dist/provider.d.ts +23 -0
  61. package/dist/provider.d.ts.map +1 -1
  62. package/dist/provider.js +244 -44
  63. package/dist/provider.js.map +1 -1
  64. package/dist/service-adapter.d.ts +6 -0
  65. package/dist/service-adapter.d.ts.map +1 -0
  66. package/dist/service-adapter.js +120 -0
  67. package/dist/service-adapter.js.map +1 -0
  68. package/dist/service.d.ts +61 -2
  69. package/dist/service.d.ts.map +1 -1
  70. package/dist/service.js +399 -84
  71. package/dist/service.js.map +1 -1
  72. package/dist/store.d.ts +10 -0
  73. package/dist/store.d.ts.map +1 -1
  74. package/dist/store.js +25 -8
  75. package/dist/store.js.map +1 -1
  76. package/dist/token.d.ts +1 -1
  77. package/dist/token.d.ts.map +1 -1
  78. package/dist/token.js +4 -1
  79. package/dist/token.js.map +1 -1
  80. package/package.json +11 -7
package/dist/service.js CHANGED
@@ -62,7 +62,7 @@ import { resolve } from "node:path";
62
62
  import { connect, credsAuthenticator } from "@nats-io/transport-node";
63
63
  import { jetstream, jetstreamManager } from "@nats-io/jetstream";
64
64
  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";
65
+ 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, epgateKey, isCasLoss, sameManagedGrantCustody } from "@cotal-ai/core";
66
66
  import { findCotalRoot, loadManagerInstanceIdentity, userAuthStateDir, workspaceSecretStore, createAuthInstanceIdentity, loadAuthInstanceIdentity } from "@cotal-ai/workspace";
67
67
  import { decodeJwt } from "jose";
68
68
  import { deriveOwnerForIdpSubject, platformControlOwner } from "./derive.js";
@@ -85,6 +85,7 @@ import { validateRetainedManagedAgent } from "./continuity.js";
85
85
  import { reconstructRemoteManagerServeGrant, remoteManagerServeGrantFromCluster, remoteManagerSurface } from "./manager-contract.js";
86
86
  import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, platformReadinessGrants, remoteManagerIssuerGrants, remoteManagerRegistrationProof, registrationExecutorGrants, servedRunRequestSubjects } from "./authority-client.js";
87
87
  import { authorizeConnectCredential } from "./connect-reader.js";
88
+ import { chatAwareAclResolver, openChatSessionAuthority } from "./chat-session-authority.js";
88
89
  import { makeNativeIssuanceRecorder, pruneNativeIssuance } from "./native-issuance.js";
89
90
  import { ensureRootCredential } from "./root-credential.js";
90
91
  import { activateLifecycleAtUid, observeGate, openLifecycleRegistry, readLifecycleHeadForOperation } from "./lifecycle-registry.js";
@@ -100,13 +101,16 @@ import { serializedFor } from "./serialized.js";
100
101
  import { joinOrStartRetirement, openAuthAdminListener } from "./auth-admin.js";
101
102
  import { AUTH_SERVICE_ENDPOINT, authClusterArtifacts, authContractArtifactValues } from "./auth-service-contract.js";
102
103
  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";
104
+ import { AGENT_BEARER_TTL_SEC, assertWithinSpawnerGrant, findInteractiveActor, findManagedActor, findActorUnified, ledgerAclResolver, ledgerActorSourceIsLive, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, revokeManagedActorIf, withManagedGrantTransaction, } from "./ledger.js";
104
105
  import { AUTH_PROVIDER_NAME, claimAuthInstanceIdentity, clearAuthServiceInfo, dataAccountAuth, loadCalloutAuth, loadIssuer, loadOwnerSecret, loadPinnedIdp, loadServiceKeys, saveAuthServiceInfo, spaceIssuer, } from "./store.js";
105
106
  /** JWKS max-age seconds — the cache contract's knob. Exported so a rotation tool can compute the
106
107
  * retire floor (max-age + max bearer TTL) from it. */
107
108
  export const JWKS_MAX_AGE_SEC = 300;
108
109
  /** Loopback-only operator route used by `cotal actor grant/revoke` before rotating or deleting an interactive lifecycle. */
109
110
  export const INTERACTIVE_RETIRE_PATH = "/interactive-lifecycle/retire";
111
+ /** The user-mode chat-session routes, on the loopback face (capability-gated) and the public face.
112
+ * Each takes a fresh IdP proof; `bearer` and `retire` also take the session's holder proof. */
113
+ export const CHAT_SESSION_PATHS = Object.freeze({ open: "/chat-session/open", bearer: "/chat-session/bearer", retire: "/chat-session/retire" });
110
114
  /** Loopback-only host route that finishes a managed lifecycle's terminal retirement after the host
111
115
  * revoked its grant, when the remote manager that should have requested it is gone (#2070). */
112
116
  export const MANAGED_RETIRE_PATH = "/managed-lifecycle/retire";
@@ -147,6 +151,11 @@ const MANAGER_PIN_REMEDY = {
147
151
  agent: "name one with --manager-instance <instanceId>",
148
152
  wire: "name one as managerInstanceId",
149
153
  };
154
+ /** An unpinned manager-caller selection that found no single live manager. It is raised only after
155
+ * the exchange's credential verified, and it describes the caller's own owner, so it is not probing:
156
+ * the exchange arms neither count it against the refused-exchange budget nor hide it behind 429. */
157
+ class ManagerSelectionRefusal extends Error {
158
+ }
150
159
  /** Package-internal policy step for the hosted retirement requester. The HTTP production door calls
151
160
  * this after deriving the owner and manager actors; broker-free smokes exercise the same decision. */
152
161
  export function authorizeRemoteManagerRetirement(args) {
@@ -222,6 +231,14 @@ async function supervisorGeneration(session, space, owner, instanceId, lifecycle
222
231
  throw new EpEnvelopeError("permission-denied", "platform supervisor accepted row names another owner or lifecycle");
223
232
  return accepted.ref;
224
233
  }
234
+ function runtimeSelection(options) {
235
+ if (!options || typeof options !== "object" || Array.isArray(options))
236
+ throw new Error("auth-service runtime options must be an object");
237
+ const selection = options.interactiveManagerSelection;
238
+ if (selection !== undefined && selection !== "local-primary")
239
+ throw new Error('interactiveManagerSelection must be "local-primary" when present');
240
+ return selection;
241
+ }
225
242
  /**
226
243
  * Open the authority plane — the PRODUCTION connect/exchange composition (exported so the live
227
244
  * deny-new smoke exercises exactly what the daemon runs). Boot order is the readiness contract:
@@ -251,6 +268,7 @@ async function openAuthAuthorityPlaneWithCustody(opts, onConnection) {
251
268
  }
252
269
  /** The body of the plane opener, which owns the unwind of a failed construction. */
253
270
  async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
271
+ const interactiveManagerSelection = runtimeSelection(opts);
254
272
  const { server, space, dataAccount, log } = opts;
255
273
  const standingTtl = opts.standingRenewableTtlSeconds ?? STANDING_RENEWABLE_TTL_SEC;
256
274
  if (!Number.isSafeInteger(standingTtl) || standingTtl < 5 || standingTtl > STANDING_RENEWABLE_TTL_SEC) {
@@ -430,11 +448,20 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
430
448
  unwind.push(() => authAdmin.close());
431
449
  const recordToolResultConsent = makeToolResultConsentRecorder(registryStores(registry).authKv, () => refuseIfFenced(), space);
432
450
  const fileArm = ledgerAuthorizeConnect(opts.dir);
451
+ // A chat principal (its alias is in the chat row space) is checked by the chat arm: its active
452
+ // row, its parent pin and its holder index. Every other principal by the grant-file arm.
453
+ const connectArm = async (t) => {
454
+ if (chat.isChatPrincipal(t.owner, t.act.actor))
455
+ await chat.authorizeConnect(t);
456
+ else
457
+ fileArm(t);
458
+ };
433
459
  // The callout's issuance evidence: create-only natkey row, then the connect authority re-read
434
- // in the same stream before the minted JWT may leave.
460
+ // in the same stream before the minted JWT may leave. For a chat session this re-read is the
461
+ // post-sign fence: its own head and root credential, its parent pin and its holder index.
435
462
  const recordIssuance = makeNativeIssuanceRecorder(registry, async (t) => {
436
463
  refuseIfFenced();
437
- fileArm(t);
464
+ await connectArm(t);
438
465
  await authorizeConnectCredential(reader.current(), t, Date.now);
439
466
  });
440
467
  // Expired evidence goes at boot (a failure refuses the open) and then on the plane's own timer;
@@ -556,6 +583,24 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
556
583
  throw new Error(`manager instance ${instanceId} is ${verdict}`);
557
584
  return instanceId;
558
585
  }
586
+ // A trusted host may choose the local Manager up front for human defaults. Explicit pins
587
+ // above and managed-agent calls keep their existing selection; no remote is a substitute.
588
+ if (caller === "human" && interactiveManagerSelection === "local-primary") {
589
+ let local;
590
+ try {
591
+ local = loadLocalManager();
592
+ }
593
+ catch {
594
+ throw new Error("interactive local-primary manager identity could not be read");
595
+ }
596
+ if (!local)
597
+ throw new Error("interactive local-primary manager identity is absent");
598
+ const instanceId = assertLifecycleToken(local.instanceId, "managerInstanceId");
599
+ const verdict = await managerGate(owner, instanceId);
600
+ if (verdict !== "candidate")
601
+ throw new Error(`interactive local-primary manager instance ${instanceId} is ${verdict}`);
602
+ return instanceId;
603
+ }
559
604
  const { instances } = await listManagerInstances(owner);
560
605
  const remoteInstances = instances.filter((i) => i.placement === "remote").map((i) => i.instanceId);
561
606
  const remoteLive = instances.filter((i) => i.placement === "remote" && i.state === "candidate").map((i) => i.instanceId);
@@ -564,12 +609,12 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
564
609
  if (remoteLive.length === 1)
565
610
  return remoteLive[0];
566
611
  if (remoteLive.length > 1)
567
- throw new Error(`manager-caller selection found ${remoteLive.length} live remote manager candidates for owner ${owner} (${remoteLive.join(", ")}); exactly one is required, so ${MANAGER_PIN_REMEDY[caller ?? "wire"]}`);
612
+ throw new ManagerSelectionRefusal(`manager-caller selection found ${remoteLive.length} live remote manager candidates for owner ${owner} (${remoteLive.join(", ")}); exactly one is required, so ${MANAGER_PIN_REMEDY[caller ?? "wire"]}`);
568
613
  if (remoteInstances.length > 0)
569
- throw new Error(`the remote manager for owner ${owner} is unavailable (${remoteInstances.length} gate${remoteInstances.length === 1 ? "" : "s"}, none live); start one with \`cotal supervise\` and retry, as this call is never routed to the local manager`);
614
+ throw new ManagerSelectionRefusal(`the remote manager for owner ${owner} is unavailable (${remoteInstances.length} gate${remoteInstances.length === 1 ? "" : "s"}, none live); start one with \`cotal supervise\` and retry, as this call is never routed to the local manager`);
570
615
  if (localCandidate !== undefined)
571
616
  return localCandidate;
572
- throw new Error(`manager-caller selection found no manager candidate for owner ${owner}`);
617
+ throw new ManagerSelectionRefusal(`manager-caller selection found no manager candidate for owner ${owner}`);
573
618
  };
574
619
  // Every authority operation refuses once the plane is fenced: a half-dead plane keeps NO face up.
575
620
  // AUDIENCE SPLIT (ux): these refusals reach a CONNECTING AGENT during the brief fence→exit
@@ -621,12 +666,94 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
621
666
  const creds = await mintCreds(issuerAuth(), newIdentity(), "deprovisioner", { deprovisionTarget: { principal: targetId, lifecycleUid, memberChannels } });
622
667
  await deprovisionAgent({ servers: server, space, targetId, lifecycleUid, memberChannels, creds });
623
668
  };
669
+ // A chat session's footprint (admission a7): its own bind-only DM and DELIVER durables and the
670
+ // read-ACL row the delivery daemon authorizes its history and durable joins from, written by a
671
+ // short-lived provisioner and read back on that same connection (part of a8).
672
+ const provisionChatFootprint = async ({ owner, actor, lifecycleUid, read }) => {
673
+ const identity = newIdentity();
674
+ let conn;
675
+ const provisioner = new CotalEndpoint({
676
+ space,
677
+ servers: server,
678
+ onConnection: (nc) => { conn = nc; onConnection?.(nc, `cotal:chat-provisioner:${space}`); },
679
+ channels: [],
680
+ creds: await mintCreds(issuerAuth(), identity, "provisioner"),
681
+ card: { id: identity.id, name: "provisioner", role: "provisioner", kind: "endpoint" },
682
+ registerPresence: false,
683
+ watchPresence: false,
684
+ watchChannels: false,
685
+ consume: false,
686
+ });
687
+ await provisioner.start();
688
+ try {
689
+ await provisionAgentDurables(provisioner, { owner, actor, lifecycleUid }, { subscribe: [], allowSubscribe: read });
690
+ if (conn === undefined)
691
+ throw new EpEnvelopeError("unavailable", "the chat provisioner connection is unavailable for the read-back", undefined, "unknown");
692
+ const jsm = await jetstreamManager(conn);
693
+ await jsm.consumers.info(dmStream(space), dmDurable(owner, actor, lifecycleUid));
694
+ await jsm.consumers.info(dlvStream(space), dlvDurable(owner, actor, lifecycleUid));
695
+ const acl = await provisioner.aclForOwner(principalKey(owner, actor).key, lifecycleUid);
696
+ if (acl === undefined || JSON.stringify(acl) !== JSON.stringify(read))
697
+ throw new EpEnvelopeError("unavailable", `the chat session's read-ACL row did not read back for ${lifecycleUid}`, undefined, "unknown");
698
+ }
699
+ finally {
700
+ await provisioner.stop();
701
+ }
702
+ };
703
+ const chat = openChatSessionAuthority({
704
+ space,
705
+ dir: opts.dir,
706
+ registry,
707
+ managerInstance: `auth-service:${space}`,
708
+ retireLifecycle: async ({ owner, actor, lifecycleUid, opId }) => {
709
+ const flight = joinOrStartRetirement(retirementFlights, barrierReg, { owner, actor, lifecycleUid, opId, frontierStreams: retirementFrontierStreams(space) }, retirement);
710
+ if (flight === undefined)
711
+ throw new EpEnvelopeError("conflict", `chat retirement operation ${opId} is already in flight for different coordinates`);
712
+ await flight;
713
+ },
714
+ provisionFootprint: provisionChatFootprint,
715
+ deprovisionFootprint: ({ owner, actor, lifecycleUid, memberChannels }) => deprovisionManagedFootprint(owner, actor, lifecycleUid, memberChannels),
716
+ now: Date.now,
717
+ log,
718
+ });
719
+ // One bounded chat sweep pass per interval. A failed pass is logged and the next one retries; it
720
+ // never fences the plane, because every chat authority decision re-reads its own records.
721
+ const chatSweepMs = opts.chatSweepMs ?? 60_000;
722
+ if (!Number.isSafeInteger(chatSweepMs) || chatSweepMs < 100 || chatSweepMs > 3_600_000)
723
+ throw new Error(`chatSweepMs must be an integer between 100 and 3600000 (got ${chatSweepMs})`);
724
+ let chatSweepTimer;
725
+ let chatSweepFlight = Promise.resolve();
726
+ const scheduleChatSweep = () => {
727
+ if (closing || fatalReason !== undefined)
728
+ return;
729
+ chatSweepTimer = setTimeout(() => {
730
+ chatSweepFlight = chat.sweepPass().catch((e) => {
731
+ if (!closing)
732
+ log(`auth-plane: chat sweep pass failed: ${e instanceof Error ? e.message : String(e)}`);
733
+ }).then(scheduleChatSweep);
734
+ }, chatSweepMs);
735
+ };
736
+ const stopChatSweep = async () => { clearTimeout(chatSweepTimer); await chatSweepFlight; };
737
+ unwind.push(async () => { closing = true; await stopChatSweep(); });
738
+ scheduleChatSweep();
624
739
  return {
625
740
  authorizeConnect: async (t) => {
626
741
  refuseIfFenced();
627
- fileArm(t);
742
+ await connectArm(t);
628
743
  await authorizeConnectCredential(reader.current(), t, Date.now);
629
744
  },
745
+ openChatSession: async (args) => {
746
+ refuseIfFenced();
747
+ return chat.admit(args);
748
+ },
749
+ chatSessionBearer: async (args) => {
750
+ refuseIfFenced();
751
+ return chat.exchange(args);
752
+ },
753
+ retireChatSession: async (args) => {
754
+ refuseIfFenced();
755
+ return chat.retire(args);
756
+ },
630
757
  recordIssuance: (i) => {
631
758
  refuseIfFenced();
632
759
  return recordIssuance(i);
@@ -691,11 +818,18 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
691
818
  }, principal, claim);
692
819
  },
693
820
  retireInteractiveLifecycle: async (args) => {
694
- refuseIfFenced();
695
- const owner = assertDerivedOwnerToken(args.owner);
696
- const actor = assertValidOwnerToken(args.actor);
697
- const lifecycleUid = assertLifecycleToken(args.lifecycleUid);
698
- const head = await readLifecycleHeadForOperation(barrierReg, owner, actor);
821
+ // Everything before the barrier only reads, so a refusal there retired nothing.
822
+ let owner, actor, lifecycleUid;
823
+ try {
824
+ refuseIfFenced();
825
+ owner = assertDerivedOwnerToken(args.owner);
826
+ actor = assertValidOwnerToken(args.actor);
827
+ lifecycleUid = assertLifecycleToken(args.lifecycleUid);
828
+ }
829
+ catch (e) {
830
+ throw notExecuted(e, "bad-request");
831
+ }
832
+ const head = await readLifecycleHeadForOperation(barrierReg, owner, actor).catch((e) => { throw notExecuted(e, "unavailable"); });
699
833
  if (head === undefined)
700
834
  return { retired: false, lifecycleUid, notStarted: true };
701
835
  if (head.mapping.lifecycleUid !== lifecycleUid) {
@@ -704,7 +838,7 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
704
838
  // An active/retiring foreign uid is different: the ledger and authority disagree, so fail.
705
839
  if (head.mapping.state === "retired")
706
840
  return { retired: false, lifecycleUid, notStarted: true };
707
- throw new EpEnvelopeError("conflict", `interactive lifecycle retirement for "${owner}/${actor}" names ${lifecycleUid}, but the authority head is ${head.mapping.state} at ${head.mapping.lifecycleUid}`);
841
+ 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");
708
842
  }
709
843
  if (head.mapping.state === "retired")
710
844
  return { retired: true, lifecycleUid, alreadyRetired: true };
@@ -1152,31 +1286,48 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
1152
1286
  const t = r.target;
1153
1287
  const subscribe = t.subscribe ?? [];
1154
1288
  const { lifecycleUid, row } = await serializedForAlias(opts.dir, owner, t.actor, async () => {
1155
- // The token digest names one enrollment. A held row with this request's digest is that
1156
- // enrollment retried, so it answers the same uid; any other held row is a lifecycle that
1157
- // still runs, and taking its grant would strand it.
1158
- const held = findManagedActor(opts.dir, owner, t.actor);
1159
- if (held !== undefined && held.tokenHash !== t.tokenHash)
1160
- throw new EpEnvelopeError("conflict", `managed agent "${t.actor}" is enrolled at lifecycle ${held.lifecycleUid}; prepare that lifecycle's retirement before enrolling a successor`);
1161
- // A fresh uid is never one a retirement tombstoned, and only the host can make that promise.
1162
- const lifecycleUid = held?.lifecycleUid ?? mintLifecycleUid();
1163
- // The grant first: its envelope walk against the supervising actor refuses an over-wide
1164
- // request before any durable exists. A retry walks the held grant again: its spawner chain
1165
- // may have narrowed since the first answer, and provisioning would restore what it took away.
1166
- if (held !== undefined)
1167
- assertWithinSpawnerGrant(opts.dir, held, "spawn");
1168
- const row = held ?? grantManagedActor(opts.dir, {
1169
- owner,
1170
- actor: t.actor,
1171
- scope: t.capabilities ?? [],
1172
- allowSubscribe: t.allowSubscribe ?? [],
1173
- allowPublish: t.allowPublish ?? [],
1174
- ...(t.role !== undefined ? { role: t.role } : {}),
1175
- ...(t.label !== undefined ? { label: t.label } : {}),
1176
- parent: principalKey(owner, r.actor).key,
1177
- lifecycleUid,
1178
- tokenHash: t.tokenHash,
1289
+ // The custody this enrollment attests: the instance the authorized request names, checked
1290
+ // against its current registration above. Never a field of the participant's target.
1291
+ const custody = { kind: "remote-manager", instanceId: r.instanceId };
1292
+ // ONE synchronous transaction for the read, the decision and the write, so an
1293
+ // enrollment in another process (or a provider grant) cannot land between them.
1294
+ const decided = withManagedGrantTransaction(opts.dir, (tx) => {
1295
+ const current = tx.read(owner, t.actor);
1296
+ const held = current?.kind === "managed-agent" ? current : undefined;
1297
+ // The token digest names one enrollment. A held row with this request's digest is that
1298
+ // enrollment retried, so it answers the same uid; any other held row is a lifecycle that
1299
+ // still runs, and taking its grant would strand it.
1300
+ if (held !== undefined && held.tokenHash !== t.tokenHash)
1301
+ throw new EpEnvelopeError("conflict", `managed agent "${t.actor}" is enrolled at lifecycle ${held.lifecycleUid}; prepare that lifecycle's retirement before enrolling a successor`);
1302
+ // A retry is this enrollment only if the held row records this exact custody. A row with
1303
+ // none, or with another, is never upgraded or adopted here.
1304
+ if (held !== undefined && (held.custody === undefined || !sameManagedGrantCustody(held.custody, custody)))
1305
+ throw new EpEnvelopeError("conflict", `managed agent "${t.actor}" is held at lifecycle ${held.lifecycleUid} under another custody; it was not adopted by this enrollment`);
1306
+ // A retry walks the held grant again: its spawner chain may have narrowed since the
1307
+ // first answer, and provisioning would restore what it took away.
1308
+ if (held !== undefined) {
1309
+ assertWithinSpawnerGrant(opts.dir, held, "spawn");
1310
+ return { lifecycleUid: held.lifecycleUid, row: held, wrote: false };
1311
+ }
1312
+ // A fresh uid is never one a retirement tombstoned, and only the host can make that
1313
+ // promise. The grant's envelope walk refuses an over-wide request before any durable exists.
1314
+ const lifecycleUid = mintLifecycleUid();
1315
+ const row = tx.grantManaged({
1316
+ owner,
1317
+ actor: t.actor,
1318
+ scope: t.capabilities ?? [],
1319
+ allowSubscribe: t.allowSubscribe ?? [],
1320
+ allowPublish: t.allowPublish ?? [],
1321
+ ...(t.role !== undefined ? { role: t.role } : {}),
1322
+ ...(t.label !== undefined ? { label: t.label } : {}),
1323
+ parent: principalKey(owner, r.actor).key,
1324
+ lifecycleUid,
1325
+ tokenHash: t.tokenHash,
1326
+ custody,
1327
+ });
1328
+ return { lifecycleUid, row, wrote: true };
1179
1329
  });
1330
+ const { lifecycleUid, row } = decided;
1180
1331
  try {
1181
1332
  const identity = newIdentity();
1182
1333
  const provisioner = new CotalEndpoint({
@@ -1202,9 +1353,10 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
1202
1353
  catch (e) {
1203
1354
  // A retried enrollment's first answer may already have reached the participant, so its
1204
1355
  // grant stays. A grant this call wrote gave the participant no uid, so it can never be
1205
- // retired: it and any durable it got must go now.
1206
- if (held === undefined) {
1207
- revokeManagedActorAt(opts.dir, owner, t.actor, lifecycleUid);
1356
+ // retired: it and any durable it got must go now. The revoke removes only the row this
1357
+ // call wrote: same uid AND same custody, read under the grant lock.
1358
+ if (decided.wrote) {
1359
+ revokeManagedActorIf(opts.dir, owner, t.actor, { lifecycleUid, custody });
1208
1360
  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)}`));
1209
1361
  }
1210
1362
  throw e;
@@ -1244,8 +1396,17 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
1244
1396
  const row = findManagedActor(opts.dir, owner, actor);
1245
1397
  if (row?.lifecycleUid === lifecycleUid) {
1246
1398
  // Footprint before grant: while the row stands, a retry still knows which channels to purge.
1399
+ // The footprint work is async, so it runs outside the grant lock.
1247
1400
  await deprovisionManagedFootprint(owner, actor, lifecycleUid, row.allowSubscribe.filter(isConcreteChannel));
1248
- revokeManagedActorAt(opts.dir, owner, actor, lifecycleUid);
1401
+ // The decision and the write are one transaction: the row removed is the one read above
1402
+ // only if it still names this uid and the custody it carried then. A row written before
1403
+ // custody existed is removed by uid alone, as before.
1404
+ withManagedGrantTransaction(opts.dir, (tx) => {
1405
+ if (row.custody !== undefined)
1406
+ tx.revokeManagedIf(owner, actor, { lifecycleUid, custody: row.custody });
1407
+ else
1408
+ tx.revokeManagedAt(owner, actor, lifecycleUid);
1409
+ });
1249
1410
  }
1250
1411
  });
1251
1412
  return {
@@ -1504,6 +1665,7 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
1504
1665
  // for a later close retry; successful scanner teardown is not run a second time.
1505
1666
  closing = true;
1506
1667
  await stopPrune();
1668
+ await stopChatSweep();
1507
1669
  const scannerFlight = closeScanners ??= (async () => {
1508
1670
  await authAdmin?.close();
1509
1671
  const results = await Promise.allSettled([reader.close(), recordsScanner.close(), scanner.close()]);
@@ -1604,19 +1766,8 @@ async function resumeOpenOperations(reg, evictPrincipal, retirement, log) {
1604
1766
  }
1605
1767
  }
1606
1768
  }
1607
- /** Run the auth service. Flags: `--space` (required), `--server` (broker URL, required), `--port`
1608
- * (loopback HTTP port; default ephemeral). All persisted material must already exist (the
1609
- * provider's `prepareServer` ran at `cotal up`) — a missing piece is a fail-loud config error
1610
- * naming the fix, never a silent partial service; this daemon LOADS the four secret kinds and
1611
- * never generates them.
1612
- *
1613
- * `store` is the hosted composition's injection point for the space's SECRET material (callout
1614
- * account, issuer keys, owner secret, service key projection) — when given, it is those kinds'
1615
- * ONLY source (no flag or path can point this daemon at other secret material; an absent key is
1616
- * the hard error below). Absent, the daemon composes the local workspace store over `.cotal/`.
1617
- * NON-SEAM state (the actor ledger, the IdP pin, the discovery file) still lives in the local
1618
- * state dir either way — hosting those is a later, separate seam. */
1619
- export async function runAuthService(args, store) {
1769
+ /** Pure native daemon input validation, shared with its explicit service launch plan. */
1770
+ export function authServiceConfiguration(args) {
1620
1771
  const v = args.values;
1621
1772
  const space = v.space;
1622
1773
  if (!space)
@@ -1647,9 +1798,27 @@ export async function runAuthService(args, store) {
1647
1798
  const badFace = publicFace && checkPublicFace(publicFace);
1648
1799
  if (badFace)
1649
1800
  throw new Error(badFace);
1801
+ return { space, server, port, publicFace };
1802
+ }
1803
+ /** Run the auth service. Flags: `--space` (required), `--server` (broker URL, required), `--port`
1804
+ * (loopback HTTP port; default ephemeral). All persisted material must already exist (the
1805
+ * provider's `prepareServer` ran at `cotal up`) — a missing piece is a fail-loud config error
1806
+ * naming the fix, never a silent partial service; this daemon LOADS the four secret kinds and
1807
+ * never generates them.
1808
+ *
1809
+ * `store` is the hosted composition's injection point for the space's SECRET material (callout
1810
+ * account, issuer keys, owner secret, service key projection) — when given, it is those kinds'
1811
+ * ONLY source (no flag or path can point this daemon at other secret material; an absent key is
1812
+ * the hard error below). Absent, the daemon composes the local workspace store over `.cotal/`.
1813
+ * NON-SEAM state (the actor ledger, the IdP pin, the discovery file) still lives in the local
1814
+ * state dir either way — hosting those is a later, separate seam. */
1815
+ export async function runAuthService(args, store, options = {}) {
1816
+ const interactiveManagerSelection = runtimeSelection(options);
1817
+ const lifecycle = "root" in options ? options : undefined;
1818
+ const { space, server, port, publicFace } = authServiceConfiguration(args);
1650
1819
  // The provider's space-scoped state dir for NON-SEAM material (ledger, IdP pin, discovery). The
1651
1820
  // layout fact is workspace-owned (userAuthStateDir); this daemon never touches `.cotal/auth/auth.json`.
1652
- const root = findCotalRoot();
1821
+ const root = lifecycle?.root ?? findCotalRoot();
1653
1822
  const dir = userAuthStateDir(root, space);
1654
1823
  const secrets = store ?? workspaceSecretStore(root);
1655
1824
  // Scrub any stale discovery file FIRST — a dead prior daemon's entry must never satisfy a
@@ -1681,25 +1850,43 @@ export async function runAuthService(args, store) {
1681
1850
  // The CLI composition's local manager is this workspace root's persisted instance, re-read per
1682
1851
  // selection exactly as before. A hosted context passes no local manager at all.
1683
1852
  localManager: () => loadManagerInstanceIdentity(root, space),
1853
+ ...(interactiveManagerSelection !== undefined ? { interactiveManagerSelection } : {}),
1684
1854
  });
1685
1855
  // All planes bound — NOW write the discovery file (its existence is the readiness signal).
1686
1856
  const { url, publicUrl, cap } = started.handle;
1687
1857
  saveAuthServiceInfo(dir, { url, pid: process.pid, cap, ...(publicUrl !== undefined ? { publicUrl } : {}) });
1688
- console.log(`✓ auth service up (space ${space}) - callout on ${server}, exchange/JWKS at ${url}${publicUrl !== undefined ? `, public exchange at ${publicUrl}` : ""}`);
1858
+ if (!lifecycle?.quiet)
1859
+ console.log(`✓ auth service up (space ${space}) - callout on ${server}, exchange/JWKS at ${url}${publicUrl !== undefined ? `, public exchange at ${publicUrl}` : ""}`);
1689
1860
  // The CLI wrapper, not the context, owns process signals and exit codes.
1690
- const stop = async () => {
1861
+ let stopping;
1862
+ const stop = () => stopping ??= (async () => {
1691
1863
  clearAuthServiceInfo(dir); // a dead service must not satisfy the next start's readiness poll
1692
1864
  try {
1693
1865
  await started.handle.close();
1866
+ await lifecycle?.onClosed();
1694
1867
  }
1695
1868
  catch (e) {
1696
- console.error(`✗ auth-service: ${e.message} - exiting`);
1869
+ console.error(lifecycle ? "✗ native auth-service shutdown did not confirm completion; custody was retained" : `✗ auth-service: ${e.message} - exiting`);
1697
1870
  process.exit(1);
1698
1871
  }
1699
1872
  process.exit(0);
1700
- };
1873
+ })();
1701
1874
  process.on("SIGINT", () => void stop());
1702
1875
  process.on("SIGTERM", () => void stop());
1876
+ if (lifecycle) {
1877
+ try {
1878
+ await lifecycle.onReady();
1879
+ }
1880
+ catch {
1881
+ clearAuthServiceInfo(dir);
1882
+ try {
1883
+ await started.handle.close();
1884
+ await lifecycle.onClosed();
1885
+ }
1886
+ catch { /* Unknown close keeps custody. */ }
1887
+ throw new Error("the native auth-service startup receipt was not published; no new launch was retried");
1888
+ }
1889
+ }
1703
1890
  // A dropped broker connection is fatal-loud, not a zombie: the supervising `up`/`down` lifecycle
1704
1891
  // owns restarts, and a callout that silently stopped answering would hang every user connect.
1705
1892
  // A FENCED plane is equally fatal (SPEC 13.13): its scanners are no longer whole, every authority
@@ -1708,13 +1895,14 @@ export async function runAuthService(args, store) {
1708
1895
  const ended = await started.ended;
1709
1896
  clearAuthServiceInfo(dir);
1710
1897
  if (ended.kind === "fenced") {
1711
- console.error(`✗ auth-service: ${ended.cause} - exiting`);
1898
+ console.error(lifecycle ? "✗ native auth-service authority was fenced; custody was retained" : `✗ auth-service: ${ended.cause} - exiting`);
1712
1899
  process.exit(1);
1713
1900
  }
1714
1901
  if (ended.cause !== undefined) {
1715
- console.error(`✗ auth-service: broker connection closed (${ended.cause}) - exiting`);
1902
+ console.error(lifecycle ? "✗ native auth-service broker connection closed; custody was retained" : `✗ auth-service: broker connection closed (${ended.cause}) - exiting`);
1716
1903
  process.exit(1);
1717
1904
  }
1905
+ await lifecycle?.onClosed();
1718
1906
  process.exit(0);
1719
1907
  }
1720
1908
  /** Start one account-scoped auth-service context. Every input is explicit: the state dir, the
@@ -1806,6 +1994,7 @@ async function startAuthContext(o) {
1806
1994
  dataAccount: { pub: keys.dataAccount.pub, signingSeed: keys.dataAccount.signingSeed },
1807
1995
  log: (l) => console.error(l),
1808
1996
  localManager: o.localManager,
1997
+ ...(o.interactiveManagerSelection !== undefined ? { interactiveManagerSelection: o.interactiveManagerSelection } : {}),
1809
1998
  ...(o.standingRenewableTtlSeconds !== undefined ? { standingRenewableTtlSeconds: o.standingRenewableTtlSeconds } : {}),
1810
1999
  }, onConnection);
1811
2000
  let nc;
@@ -1839,7 +2028,9 @@ async function startAuthContext(o) {
1839
2028
  token: { key: issuer.localKeySet(), issuer: issuer.issuer },
1840
2029
  authorizeActor: plane.authorizeConnect,
1841
2030
  recordIssuance: plane.recordIssuance,
1842
- permissionsFor: calloutPermissions(ledgerAclResolver(dir), plane.authorizeManagerCaller, plane.verifySession, plane.issueUserCaller),
2031
+ // A chat principal resolves from its closed row (the agent leaf, no role); every other
2032
+ // principal from the grant ledger.
2033
+ permissionsFor: calloutPermissions(chatAwareAclResolver(dir, ledgerAclResolver(dir)), plane.authorizeManagerCaller, plane.verifySession, plane.issueUserCaller),
1843
2034
  log: (l) => console.error(l),
1844
2035
  });
1845
2036
  // The subscription must be ON the broker before readiness is signaled — an `up` that recorded a
@@ -1854,6 +2045,7 @@ async function startAuthContext(o) {
1854
2045
  issuer,
1855
2046
  authorizeActor: ledgerAuthorizeGrant(dir),
1856
2047
  mintConnectCredential: plane.mintConnectCredential,
2048
+ ...(idp.dmReaderAudience !== undefined ? { dmReaderAudience: idp.dmReaderAudience } : {}),
1857
2049
  });
1858
2050
  const cap = randomBytes(32).toString("hex"); // per-start exchange capability (rotates with the daemon)
1859
2051
  // Set once the public face binds. An enrolled child's bearer exchanges there without the
@@ -1900,6 +2092,9 @@ async function startAuthContext(o) {
1900
2092
  selectManagerInstance: plane.selectManagerInstance,
1901
2093
  listManagerInstances: plane.listManagerInstances,
1902
2094
  verifySession: plane.verifySession,
2095
+ openChatSession: plane.openChatSession,
2096
+ chatSessionBearer: plane.chatSessionBearer,
2097
+ retireChatSession: plane.retireChatSession,
1903
2098
  };
1904
2099
  const loopback = createServer((req, res) => void handle(req, res, ctx));
1905
2100
  http = loopback;
@@ -2278,6 +2473,13 @@ class RequestBodyError extends Error {
2278
2473
  this.status = status;
2279
2474
  }
2280
2475
  }
2476
+ /** `e`, raised before any write, as a refusal that states so (SPEC 13.3). An envelope error keeps
2477
+ * its code; any other error takes `code`. */
2478
+ function notExecuted(e, code) {
2479
+ return e instanceof EpEnvelopeError
2480
+ ? new EpEnvelopeError(e.code, e.message, e.details, "not-executed")
2481
+ : new EpEnvelopeError(code, e instanceof Error ? e.message : String(e), undefined, "not-executed");
2482
+ }
2281
2483
  function sendRequestError(res, e) {
2282
2484
  if (e instanceof RequestBodyError)
2283
2485
  return send(res, e.status, { error: e.message });
@@ -2306,13 +2508,16 @@ const ROUTES = new Map([
2306
2508
  [VERIFY_ENROLLMENT_PATH, handleVerifyManagedAgentEnrollment],
2307
2509
  [TOOL_RESULT_HUMAN_PROOF_PATH, handleToolResultHumanProof],
2308
2510
  [TOOL_RESULT_CONSENT_PATH, handleToolResultConsent],
2511
+ [CHAT_SESSION_PATHS.open, (req, res, ctx) => handleChatSession(req, res, ctx, LOOPBACK_POLICY, "open")],
2512
+ [CHAT_SESSION_PATHS.bearer, (req, res, ctx) => handleChatSession(req, res, ctx, LOOPBACK_POLICY, "bearer")],
2513
+ [CHAT_SESSION_PATHS.retire, (req, res, ctx) => handleChatSession(req, res, ctx, LOOPBACK_POLICY, "retire")],
2309
2514
  ]);
2310
2515
  /** Route one HTTP request against the loopback route table. Errors are JSON `{ error }`. */
2311
2516
  async function handle(req, res, ctx) {
2312
2517
  try {
2313
2518
  const route = ROUTES.get(req.url ?? "");
2314
2519
  if (!route)
2315
- 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" });
2520
+ 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" });
2316
2521
  await route(req, res, ctx);
2317
2522
  }
2318
2523
  catch (e) {
@@ -2320,34 +2525,50 @@ async function handle(req, res, ctx) {
2320
2525
  }
2321
2526
  }
2322
2527
  const INTERACTIVE_RETIRE_KEYS = new Set(["owner", "actor", "lifecycleUid"]);
2528
+ /** The interactive retirement door. Every refusal before the barrier starts retired nothing and
2529
+ * answers `outcome: "not-executed"` (SPEC 13.3), with the plane's native `code` when the plane
2530
+ * raised it. A failure inside the barrier answers 400 with no outcome, which reads as unknown. */
2323
2531
  async function handleInteractiveLifecycleRetirement(req, res, ctx) {
2532
+ const before = { outcome: "not-executed" };
2324
2533
  if (req.method !== "POST")
2325
- return send(res, 405, { error: "POST only" });
2534
+ return send(res, 405, { error: "POST only", ...before });
2326
2535
  if (req.headers.origin !== undefined)
2327
- return send(res, 403, { error: "browser-origin requests are not served here" });
2536
+ return send(res, 403, { error: "browser-origin requests are not served here", ...before });
2328
2537
  if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
2329
- return send(res, 415, { error: "content-type must be application/json" });
2538
+ return send(res, 415, { error: "content-type must be application/json", ...before });
2330
2539
  if (req.headers.authorization !== `Bearer ${ctx.cap}`)
2331
- return send(res, 401, { error: "missing/invalid exchange capability - interactive lifecycle retirement is a loopback operator action" });
2540
+ return send(res, 401, { error: "missing/invalid exchange capability - interactive lifecycle retirement is a loopback operator action", ...before });
2332
2541
  const body = await readJsonBody(req);
2333
2542
  if (body === null || typeof body !== "object" || Array.isArray(body))
2334
- return send(res, 400, { error: "interactive lifecycle retirement needs { owner, actor, lifecycleUid }" });
2543
+ return send(res, 400, { error: "interactive lifecycle retirement needs { owner, actor, lifecycleUid }", ...before });
2335
2544
  for (const key of Object.keys(body))
2336
2545
  if (!INTERACTIVE_RETIRE_KEYS.has(key))
2337
- return send(res, 400, { error: `interactive lifecycle retirement carries the unknown field "${key}"` });
2546
+ return send(res, 400, { error: `interactive lifecycle retirement carries the unknown field "${key}"`, ...before });
2338
2547
  const raw = body;
2339
2548
  if (typeof raw.owner !== "string" || typeof raw.actor !== "string" || typeof raw.lifecycleUid !== "string")
2340
- return send(res, 400, { error: "interactive lifecycle retirement needs string { owner, actor, lifecycleUid }" });
2341
- const owner = assertDerivedOwnerToken(raw.owner);
2342
- const actor = assertValidOwnerToken(raw.actor);
2343
- const lifecycleUid = assertLifecycleToken(raw.lifecycleUid);
2549
+ return send(res, 400, { error: "interactive lifecycle retirement needs string { owner, actor, lifecycleUid }", ...before });
2550
+ let owner, actor, lifecycleUid;
2551
+ try {
2552
+ owner = assertDerivedOwnerToken(raw.owner);
2553
+ actor = assertValidOwnerToken(raw.actor);
2554
+ lifecycleUid = assertLifecycleToken(raw.lifecycleUid);
2555
+ }
2556
+ catch (e) {
2557
+ return send(res, 400, { error: e instanceof Error ? e.message : String(e), ...before });
2558
+ }
2344
2559
  const row = findInteractiveActor(ctx.dir, owner, actor);
2345
2560
  if (!row)
2346
- return send(res, 404, { error: `interactive actor "${owner}/${actor}" is not granted` });
2561
+ return send(res, 404, { error: `interactive actor "${owner}/${actor}" is not granted`, ...before });
2347
2562
  if (row.lifecycleUid !== lifecycleUid)
2348
- return send(res, 409, { error: `interactive actor "${owner}/${actor}" is current at lifecycle ${row.lifecycleUid ?? "<missing>"}, not ${lifecycleUid}` });
2349
- const result = await ctx.retireInteractiveLifecycle({ owner, actor, lifecycleUid });
2350
- return send(res, 200, result);
2563
+ return send(res, 409, { error: `interactive actor "${owner}/${actor}" is current at lifecycle ${row.lifecycleUid ?? "<missing>"}, not ${lifecycleUid}`, ...before });
2564
+ try {
2565
+ return send(res, 200, await ctx.retireInteractiveLifecycle({ owner, actor, lifecycleUid }));
2566
+ }
2567
+ catch (e) {
2568
+ if (e instanceof EpEnvelopeError && e.outcome === "not-executed")
2569
+ return send(res, REFUSAL_STATUS[e.code] ?? 500, { error: e.message, code: e.code, outcome: e.outcome });
2570
+ throw e;
2571
+ }
2351
2572
  }
2352
2573
  /** The host's terminal step for a managed lifecycle whose remote manager is gone: the same request
2353
2574
  * guards as the interactive door, but it requires the managed grant to be REVOKED first (the
@@ -2482,15 +2703,15 @@ async function handleVerifyManagedAgentEnrollment(req, res, ctx) {
2482
2703
  }
2483
2704
  /** Refuse a missing/invalid loopback capability, audited and counted in its own window (see
2484
2705
  * BAD_CAP_PER_MIN): 401 with `reason` until the window is full, then 429. */
2485
- function refuseCapability(res, ctx, reason) {
2706
+ function refuseCapability(res, ctx, reason, extra = {}) {
2486
2707
  const now = Date.now();
2487
2708
  while (ctx.badCaps.length && now - ctx.badCaps[0] > 60_000)
2488
2709
  ctx.badCaps.shift();
2489
2710
  ctx.badCaps.push(now);
2490
2711
  console.error("auth-service: rejected an exchange with a missing/invalid capability");
2491
2712
  if (ctx.badCaps.length > BAD_CAP_PER_MIN)
2492
- return send(res, 429, { error: "too many invalid-capability attempts - wait a minute and retry" });
2493
- send(res, 401, { error: reason });
2713
+ return send(res, 429, { error: "too many invalid-capability attempts - wait a minute and retry", ...extra });
2714
+ send(res, 401, { error: reason, ...extra });
2494
2715
  }
2495
2716
  /** The exchange body, shared by every face; `policy` says how this face proves and attributes the
2496
2717
  * caller. Behavior on the loopback face is unchanged from the pre-route-table handler. */
@@ -2594,9 +2815,11 @@ async function handleExchange(req, res, ctx, policy) {
2594
2815
  return send(res, 200, { token, owner, exp, ...(selectedManagerInstanceId ? { managerInstanceId: selectedManagerInstanceId } : {}) });
2595
2816
  }
2596
2817
  catch (e) {
2597
- policy.recordFailure(ctx, peer);
2598
2818
  const reason = e instanceof Error ? e.message : String(e);
2599
2819
  console.error(`auth-service: refused an agent exchange: ${reason}`);
2820
+ if (e instanceof ManagerSelectionRefusal)
2821
+ return send(res, 401, { error: reason });
2822
+ policy.recordFailure(ctx, peer);
2600
2823
  if (peerThrottled)
2601
2824
  return send(res, 429, { error: "too many refused exchanges - wait a minute and retry" });
2602
2825
  return send(res, 401, { error: reason });
@@ -2629,14 +2852,103 @@ async function handleExchange(req, res, ctx, policy) {
2629
2852
  catch (e) {
2630
2853
  // A refused exchange (bad IdP token, ungranted actor, expired proof) is an AUTHENTICATED
2631
2854
  // denial with the reason — the client shows it to the operator verbatim.
2632
- policy.recordFailure(ctx, peer);
2633
2855
  const reason = e instanceof Error ? e.message : String(e);
2634
2856
  console.error(`auth-service: refused an exchange: ${reason}`);
2857
+ if (e instanceof ManagerSelectionRefusal)
2858
+ return send(res, 401, { error: reason });
2859
+ policy.recordFailure(ctx, peer);
2635
2860
  if (peerThrottled)
2636
2861
  return send(res, 429, { error: "too many refused exchanges - wait a minute and retry" });
2637
2862
  return send(res, 401, { error: reason });
2638
2863
  }
2639
2864
  }
2865
+ /** Each chat-session route's CLOSED body: an unknown or missing field refuses before any read. */
2866
+ const CHAT_SESSION_FIELDS = {
2867
+ open: { required: ["idpToken", "holderHash", "read", "post", "ttlSec"], optional: ["label"] },
2868
+ bearer: { required: ["idpToken", "actor", "holderProof"], optional: [] },
2869
+ retire: { required: ["idpToken", "actor", "lifecycleUid", "holderProof"], optional: [] },
2870
+ };
2871
+ /** HTTP status for a door refusal's native code; the body carries the code itself. */
2872
+ const REFUSAL_STATUS = {
2873
+ "bad-request": 400, unauthenticated: 401, "permission-denied": 403, "not-found": 404, conflict: 409,
2874
+ "failed-precondition": 412, "resource-exhausted": 429, unimplemented: 501, unavailable: 503,
2875
+ };
2876
+ /**
2877
+ * The chat-session routes, on both faces. The owner is derived from a fresh IdP proof exactly as
2878
+ * `/exchange` derives it; the body never names one. `open` admits, `bearer` exchanges the holder
2879
+ * proof for one short bearer, `retire` retires. Every refusal answers `{ error, code, outcome }`:
2880
+ * `not-executed` when nothing was written, `executed` when this request wrote before refusing, and
2881
+ * `unknown` when a write may have landed (a retry with the SAME holder resolves it). The holder proof
2882
+ * and the bearer never reach a log line.
2883
+ */
2884
+ async function handleChatSession(req, res, ctx, policy, op) {
2885
+ // Every answer before the dispatch is refused with nothing read or written.
2886
+ const before = { outcome: "not-executed" };
2887
+ if (req.method !== "POST")
2888
+ return send(res, 405, { error: "POST only", code: "bad-request", ...before });
2889
+ if (req.headers.origin !== undefined)
2890
+ return send(res, 403, { error: "browser-origin requests are not served here", code: "permission-denied", ...before });
2891
+ if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
2892
+ return send(res, 415, { error: "content-type must be application/json", code: "bad-request", ...before });
2893
+ if (policy.requireCapability && (req.headers.authorization ?? "") !== `Bearer ${ctx.cap}`)
2894
+ return refuseCapability(res, ctx, "missing/invalid exchange capability - read it from the space's auth-service.json", { code: "unauthenticated", ...before });
2895
+ const peer = policy.peerKey(req);
2896
+ const peerThrottled = policy.throttled(ctx, peer);
2897
+ const body = await readJsonBody(req);
2898
+ const malformed = (why) => send(res, 400, { error: `chat-session ${op}: ${why}`, code: "bad-request", ...before });
2899
+ if (body === null || typeof body !== "object" || Array.isArray(body))
2900
+ return malformed("the body must be a JSON object");
2901
+ const b = body;
2902
+ const fields = CHAT_SESSION_FIELDS[op];
2903
+ const extra = Object.keys(b).find((k) => !fields.required.includes(k) && !fields.optional.includes(k));
2904
+ if (extra !== undefined)
2905
+ return malformed(`unknown field "${extra}"`);
2906
+ const missing = fields.required.find((k) => !Object.hasOwn(b, k));
2907
+ if (missing !== undefined)
2908
+ return malformed(`missing field "${missing}"`);
2909
+ if (typeof b.idpToken !== "string" || b.idpToken === "")
2910
+ return malformed("idpToken must be a non-empty string");
2911
+ 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")))
2912
+ return malformed("open takes { idpToken, holderHash: string, read: string[], post: string[], ttlSec: number, label?: string }");
2913
+ if (op !== "open" && (typeof b.actor !== "string" || typeof b.holderProof !== "string" || (op === "retire" && typeof b.lifecycleUid !== "string")))
2914
+ return malformed(op === "bearer" ? "bearer takes { idpToken, actor: string, holderProof: string }" : "retire takes { idpToken, actor: string, lifecycleUid: string, holderProof: string }");
2915
+ // A refusal keeps the effect outcome its source stated; one that states none is unknown.
2916
+ const refuse = (status, message, code, outcome = "unknown") => {
2917
+ policy.recordFailure(ctx, peer);
2918
+ console.error(`auth-service: refused a chat-session ${op}: ${message}`);
2919
+ if (peerThrottled)
2920
+ return send(res, 429, { error: "too many refused exchanges - wait a minute and retry", code: "resource-exhausted", outcome });
2921
+ return send(res, status, { error: message, code, outcome });
2922
+ };
2923
+ let owner;
2924
+ let idpExp;
2925
+ try {
2926
+ const verified = await verifyIdpToken(b.idpToken, ctx.bridgeIdp);
2927
+ owner = deriveOwnerForIdpSubject(ctx.ownerSecret, ctx.bridgeIdp.issuer, verified.sub);
2928
+ idpExp = verified.exp;
2929
+ }
2930
+ catch (e) {
2931
+ return refuse(401, e instanceof Error ? e.message : String(e), "unauthenticated", "not-executed");
2932
+ }
2933
+ try {
2934
+ if (op === "open")
2935
+ return send(res, 200, await ctx.openChatSession({
2936
+ owner, holderHash: b.holderHash, read: b.read, post: b.post, ttlSec: b.ttlSec,
2937
+ ...(typeof b.label === "string" ? { label: b.label } : {}),
2938
+ }));
2939
+ if (op === "bearer") {
2940
+ const minted = await ctx.chatSessionBearer({ owner, actor: b.actor, holderProof: b.holderProof, idpExp, issuer: ctx.issuer });
2941
+ return send(res, 200, { token: minted.token, owner, exp: minted.exp });
2942
+ }
2943
+ return send(res, 200, await ctx.retireChatSession({ owner, actor: b.actor, lifecycleUid: b.lifecycleUid, holderProof: b.holderProof }));
2944
+ }
2945
+ catch (e) {
2946
+ if (e instanceof EpEnvelopeError)
2947
+ return refuse(REFUSAL_STATUS[e.code] ?? 500, e.message, e.code, e.outcome ?? "unknown");
2948
+ // Anything else came from a store, broker or file read partway through: its effect is unknown.
2949
+ return refuse(503, e instanceof Error ? e.message : String(e), "unavailable", "unknown");
2950
+ }
2951
+ }
2640
2952
  /** The signed-in person's own Manager instances, read-only, on both faces. The proof is the IdP JWT
2641
2953
  * the human exchange verifies, and the owner is derived from it the same way, so a caller lists only
2642
2954
  * its own owner's gates. A browser-origin request is refused as on every exchange route; an
@@ -2882,6 +3194,9 @@ function makePublicHandler(ctx, policy, bundle) {
2882
3194
  ["/exchange", (req, res, c) => handleExchange(req, res, c, policy)],
2883
3195
  ["/manager-service-authority", (req, res, c) => handleManagerServiceAuthority(req, res, c, policy)],
2884
3196
  ["/manager-instances", (req, res, c) => handleManagerInstances(req, res, c, policy)],
3197
+ [CHAT_SESSION_PATHS.open, (req, res, c) => handleChatSession(req, res, c, policy, "open")],
3198
+ [CHAT_SESSION_PATHS.bearer, (req, res, c) => handleChatSession(req, res, c, policy, "bearer")],
3199
+ [CHAT_SESSION_PATHS.retire, (req, res, c) => handleChatSession(req, res, c, policy, "retire")],
2885
3200
  [
2886
3201
  "/.well-known/cotal-mesh",
2887
3202
  (req, res) => {
@@ -2914,7 +3229,7 @@ function makePublicHandler(ctx, policy, bundle) {
2914
3229
  try {
2915
3230
  const route = routes.get(req.url ?? "");
2916
3231
  if (!route)
2917
- return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /manager-instances, /.well-known/cotal-mesh" });
3232
+ 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" });
2918
3233
  await route(req, res, ctx);
2919
3234
  }
2920
3235
  catch (e) {