@cotal-ai/manager 0.48.1 → 0.49.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 (50) hide show
  1. package/README.md +4 -2
  2. package/dist/commands.d.ts +1 -0
  3. package/dist/commands.d.ts.map +1 -1
  4. package/dist/commands.js +136 -41
  5. package/dist/commands.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/manager-service-contract.d.ts.map +1 -1
  10. package/dist/manager-service-contract.js +17 -2
  11. package/dist/manager-service-contract.js.map +1 -1
  12. package/dist/manager.d.ts +158 -26
  13. package/dist/manager.d.ts.map +1 -1
  14. package/dist/manager.js +623 -198
  15. package/dist/manager.js.map +1 -1
  16. package/dist/remote-authority.d.ts +22 -2
  17. package/dist/remote-authority.d.ts.map +1 -1
  18. package/dist/remote-authority.js +124 -2
  19. package/dist/remote-authority.js.map +1 -1
  20. package/dist/remote-register.d.ts.map +1 -1
  21. package/dist/remote-register.js +5 -1
  22. package/dist/remote-register.js.map +1 -1
  23. package/dist/resume.d.ts +5 -0
  24. package/dist/resume.d.ts.map +1 -1
  25. package/dist/resume.js +14 -2
  26. package/dist/resume.js.map +1 -1
  27. package/dist/run-hosting.d.ts +24 -3
  28. package/dist/run-hosting.d.ts.map +1 -1
  29. package/dist/run-hosting.js +97 -8
  30. package/dist/run-hosting.js.map +1 -1
  31. package/dist/runtime/custodial-pty.d.ts +10 -3
  32. package/dist/runtime/custodial-pty.d.ts.map +1 -1
  33. package/dist/runtime/custodial-pty.js +27 -5
  34. package/dist/runtime/custodial-pty.js.map +1 -1
  35. package/dist/runtime/index.d.ts +37 -0
  36. package/dist/runtime/index.d.ts.map +1 -1
  37. package/dist/runtime/index.js +15 -0
  38. package/dist/runtime/index.js.map +1 -1
  39. package/dist/runtime/pty.d.ts.map +1 -1
  40. package/dist/runtime/pty.js +28 -26
  41. package/dist/runtime/pty.js.map +1 -1
  42. package/dist/session/plane.d.ts +2 -1
  43. package/dist/session/plane.d.ts.map +1 -1
  44. package/dist/session/plane.js +3 -2
  45. package/dist/session/plane.js.map +1 -1
  46. package/dist/static-lifecycle.d.ts +1 -0
  47. package/dist/static-lifecycle.d.ts.map +1 -1
  48. package/dist/static-lifecycle.js +5 -0
  49. package/dist/static-lifecycle.js.map +1 -1
  50. package/package.json +11 -10
package/dist/manager.js CHANGED
@@ -4,9 +4,9 @@ import { hostname } from "node:os";
4
4
  import { credsAuthenticator } from "@nats-io/transport-node";
5
5
  import { existsSync, lstatSync, readFileSync, rmSync } from "node:fs";
6
6
  import { join, dirname, resolve } from "node:path";
7
- import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, mintCreds, mintLifecycleUid, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, epRequestSubject, epCallerReplyFilter, parseEpSubject, controlServiceSubject, eventChannelPrincipal, } from "@cotal-ai/core";
8
- import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, saveManagerInstanceIdentity, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
9
- import { createRuntime, requireRuntimeAdopt, } from "./runtime/index.js";
7
+ import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, divergentSecretStoreRefusal, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, epRequestSubject, epCallerReplyFilter, parseEpSubject, controlServiceSubject, eventChannelPrincipal, } from "@cotal-ai/core";
8
+ import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, createManagerInstanceIdentity, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
9
+ import { createRuntime, isCustodialRuntime, requireRuntimeAdopt, requireRuntimeReap, } from "./runtime/index.js";
10
10
  import { AttachEndpoint } from "./attach-endpoint.js";
11
11
  import { makeManagerEndpointEvictionEvidence, makeManagerEndpointEvictor } from "./endpoint-evict.js";
12
12
  import { makeManagerHolderLivenessProbe } from "./holder-liveness.js";
@@ -16,7 +16,7 @@ import { authorizeLaunch, authorizeNamedControl } from "./authorize.js";
16
16
  import { controlShutdown } from "./control-shutdown.js";
17
17
  import { RunHosting } from "./run-hosting.js";
18
18
  import { controlSession } from "./control-session.js";
19
- import { parseResumeCommitArgs, parseResumeControlArgs, parseResumeFinalizeArgs } from "./resume.js";
19
+ import { MAX_AGENTS, parseResumeCommitArgs, parseResumeControlArgs, parseResumeFinalizeArgs } from "./resume.js";
20
20
  // Unit B (the static §13.1 lifecycle executor): the shared grammar/stores from core plus the
21
21
  // manager-side adapter (transport + slot orchestration + the F1 terminal) — see static-lifecycle.ts.
22
22
  import { jetstream, jetstreamManager } from "@nats-io/jetstream";
@@ -29,12 +29,9 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
29
29
  // P2 item 1 (1a-serve): the manager as an ordinary v0.4 `service` endpoint — the §13.1
30
30
  // endpoint-serve credential subsystem (gate provisioning, registration barrier, mint fence) plus
31
31
  // the register/authorize/serve seams, all driven over a scoped one-shot executor connection.
32
- import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, registerServiceInstance, deregisterServiceInstance, authorizeServeGrant, writeServiceStatus, SERVICE_READY, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, settleGoalUncertain, readGoalResult, readGoalStatus, readGoalSpec, recordGoalIndex, readGoalIndex, clearGoalIndex, listGoalIndex, mintCheckpoint, resumeCheckpoint, readCheckpointSettle, readCheckpointSpec, expireCheckpoint, GOAL_TERMINAL_STATES, goalRefOf, goalProgressTopic, epeSubject, submissionFingerprint, EpEnvelopeError, lifecycleBlocked, lifecycleBlockedFrom, renderLifecycleBlocked, } from "@cotal-ai/core";
32
+ import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, readEndpointGateGeneration, registerServiceInstance, deregisterServiceInstance, authorizeServeGrant, writeServiceStatus, SERVICE_READY, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, settleGoalUncertain, readGoalResult, readGoalStatus, readGoalSpec, recordGoalIndex, readGoalIndex, clearGoalIndex, listGoalIndex, mintCheckpoint, resumeCheckpoint, readCheckpointSettle, readCheckpointSpec, expireCheckpoint, GOAL_TERMINAL_STATES, goalRefOf, goalProgressTopic, epeSubject, submissionFingerprint, EpEnvelopeError, lifecycleBlocked, lifecycleBlockedFrom, renderLifecycleBlocked, } from "@cotal-ai/core";
33
33
  import { MANAGER_ENDPOINT, managerClusterArtifacts, managerCommandDefs, managerContractArtifactValues } from "./manager-service-contract.js";
34
34
  import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, observeStaticSlot, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
35
- /** Concurrency ceiling — the manager refuses to hold more than this many live + in-flight +
36
- * cooling slots at once (P4a). Bounds a fork-bomb: spawn is a full agent process per call. */
37
- const MAX_AGENTS = 50;
38
35
  /** Minimum slot lifetime for rate-flooring (P4c). A slot freed (by despawn OR natural exit/reap)
39
36
  * before living this long leaves a cooling stamp that still counts toward the ceiling until it
40
37
  * expires — so churn (spawn↔despawn or spawn↔fast-exit) can't outrun the concurrency bound. */
@@ -106,7 +103,7 @@ const STATIC_RECONCILE_MAX_ATTEMPTS = STATIC_RECONCILE_RETRY_DELAYS_MS.length +
106
103
  * operation (the rail's idempotence table needs exactly one op per retiring incarnation). 26 hex
107
104
  * chars = in the lifecycle-token grammar `[a-z0-9]{26,32}`, collision-resistant. */
108
105
  function retireOpId(lifecycleUid) {
109
- return createHash("sha256").update(`retire:${lifecycleUid}`).digest("hex").slice(0, 26);
106
+ return managedRetirementOpId(lifecycleUid);
110
107
  }
111
108
  /** Sentinel owner-filter value that matches NO agent's `userOwner` (owner tokens never contain a
112
109
  * dash) — what {@link Manager.psOwnerFilter} returns for an unparseable caller so a malformed
@@ -252,6 +249,20 @@ function foreignEventChannels(channels, owner, actor) {
252
249
  return p !== null && !(p.owner === owner && p.actor === actor);
253
250
  });
254
251
  }
252
+ function injectedManagerStoreIdentity(store) {
253
+ if (store.identity !== undefined)
254
+ return parseSecretStoreIdentity(store.identity);
255
+ const coordinate = process.env.COTAL_SECRET_STORE;
256
+ if (!coordinate)
257
+ throw new Error("ManagerOptions.secretStore must declare its identity or set COTAL_SECRET_STORE so the manager and delivery daemon can name the same authority (never a silent local-root fallback)");
258
+ return { kind: "injected", coordinate };
259
+ }
260
+ /** True only when the delivery-admin rail has no bound responder. A timeout is a hung rail, not absence. */
261
+ function isAbsentDeliveryAdmin(msg) {
262
+ if (/timeout/i.test(msg))
263
+ return false;
264
+ return /no responders|\b503\b/i.test(msg);
265
+ }
255
266
  export class Manager {
256
267
  space;
257
268
  servers;
@@ -265,12 +276,15 @@ export class Manager {
265
276
  /** The ONE secret store for every kind this manager touches (daemon-cred remint + agent kinds).
266
277
  * See {@link ManagerOptions.secretStore}. */
267
278
  secrets;
279
+ /** Named identity of {@link secrets}: the same coordinate the delivery daemon must reload from. */
280
+ secretStoreIdentity;
268
281
  /** See {@link ManagerOptions.installedExtensions}. */
269
282
  installedExtensions;
270
283
  runtime;
271
284
  /** Internal test seam. Production leaves this undefined and uses the scoped delivery-admin evictor. */
272
285
  staticLifecycleEvict;
273
286
  preserveStopTimeoutMs;
287
+ endpointServeExecutorExpiresInSeconds;
274
288
  agents = new Map();
275
289
  /** Names whose spawn is in flight (reserved synchronously before the provision await) — counted
276
290
  * toward the ceiling so two concurrent same-name spawns can't both pass the gate (P4a). */
@@ -286,6 +300,12 @@ export class Manager {
286
300
  * truth is the auth-side lifecycle head itself (an unretired head refuses issuance — the
287
301
  * named residual this belt narrows, not replaces). */
288
302
  retiring = new Map();
303
+ /** Exact predecessor incarnations whose full hosted retirement reached a terminal answer. Presence
304
+ * is advisory and can retain that old lifecycle briefly after its process exits. Resume may ignore
305
+ * only this exact (alias, principal, lifecycleUid) row when adopting a different lifecycle; every
306
+ * unknown, current, or differently identified live row still refuses. A later retirement of the
307
+ * same alias replaces its prior predecessor coordinate. */
308
+ confirmedRetiredPredecessors = new Map();
289
309
  /** SINGLE-FLIGHT guard for {@link requestRetirement} (audit #1): one in-flight rail round-trip per
290
310
  * (name, lifecycleUid). The detached `deprovision` call and every same-name-spawn nudge for THAT
291
311
  * lifecycle JOIN the same promise instead of stacking independent requests that dual-enter the
@@ -502,9 +522,13 @@ export class Manager {
502
522
  if (opts.remoteAuthority)
503
523
  this.managerLifecycleUid = opts.remoteAuthority.lifecycleUid;
504
524
  this.secrets = opts.secretStore ?? workspaceSecretStore(this.workspaceRoot);
525
+ this.secretStoreIdentity = opts.secretStore
526
+ ? injectedManagerStoreIdentity(opts.secretStore)
527
+ : { kind: "fs", root: resolve(this.workspaceRoot) };
505
528
  this.installedExtensions = opts.installedExtensions ?? false;
506
529
  this.runtime = createRuntime(opts.runtime ?? "auto", `cotal-${this.space}`);
507
530
  this.preserveStopTimeoutMs = opts.preserveStopTimeoutMs ?? PRESERVE_STOP_TIMEOUT_MS;
531
+ this.endpointServeExecutorExpiresInSeconds = opts.endpointServeExecutorExpiresInSeconds;
508
532
  if (opts.resumeAttemptId && !/^[A-Za-z0-9_-]{1,128}$/.test(opts.resumeAttemptId))
509
533
  throw new Error("resumeAttemptId must be a safe token (letters, digits, _, -; max 128)");
510
534
  if (opts.resumeDurableCommitToken && !/^[a-f0-9]{64}$/.test(opts.resumeDurableCommitToken))
@@ -532,6 +556,10 @@ export class Manager {
532
556
  get runtimeKind() {
533
557
  return this.runtime.kind;
534
558
  }
559
+ /** Whether a default process stop can drop manager-local custody without taking agents with it. */
560
+ get canSpareAgents() {
561
+ return this.runtime.kind !== "pty" || this.runtime.supportsRelease === true;
562
+ }
535
563
  /** Reattach this manager's runtime to a durable handle. Refuses by name when adopt is absent. */
536
564
  adoptRuntimeHandle(reference) {
537
565
  return requireRuntimeAdopt(this.runtime, reference);
@@ -620,6 +648,12 @@ export class Manager {
620
648
  // refused loud (no-fallbacks - a restart never silently becomes a fresh instance). A second
621
649
  // manager in a DIFFERENT workspace root is a DIFFERENT logical id by construction (its own state
622
650
  // dir) - two managers in ONE space are two workspace roots.
651
+ //
652
+ // First mint on a fresh root is exclusive create (#1263). Two processes that both observe no
653
+ // file must not keep different in-memory ids: of N concurrent starts exactly one creates the
654
+ // file (`link` / O_EXCL) and the others adopt that identity before taking a lease, or refuse
655
+ // with manager-instance-identity-create-lost. Atomic rename is not enough, because the loser
656
+ // of a replace would still serve under the id it minted.
623
657
  {
624
658
  if (this.remoteAuthority) {
625
659
  this.managerInstanceId = this.remoteAuthority.instanceId;
@@ -632,9 +666,12 @@ export class Manager {
632
666
  this.managerServeIdentity = persisted.serveIdentity;
633
667
  }
634
668
  else {
635
- this.managerInstanceId = mintLifecycleUid();
636
- this.managerServeIdentity = newIdentity();
637
- saveManagerInstanceIdentity(this.workspaceRoot, this.space, { instanceId: this.managerInstanceId, serveIdentity: this.managerServeIdentity });
669
+ const claimed = createManagerInstanceIdentity(this.workspaceRoot, this.space, {
670
+ instanceId: mintLifecycleUid(),
671
+ serveIdentity: newIdentity(),
672
+ });
673
+ this.managerInstanceId = claimed.instanceId;
674
+ this.managerServeIdentity = claimed.serveIdentity;
638
675
  }
639
676
  }
640
677
  }
@@ -714,9 +751,10 @@ export class Manager {
714
751
  this.leaseTimer.unref?.();
715
752
  // Unit B (static §13.1): after this instance holds its lease, collect the durable static rows
716
753
  // now, but do not let their exact-op terminals make the whole space unreachable. The service
717
- // comes up below, then the sweep overlaps the remaining registration work. `reconcilingAliases`
718
- // keeps the old no-race property at the actual conflict boundary: a caller cannot spawn or
719
- // attach THAT alias until its terminal attempt returns.
754
+ // comes up below, then the sweep overlaps the remaining registration work. Two-window
755
+ // heal-then-register is slower than one executor, but the overlap is still the no-outage
756
+ // property: `reconcilingAliases` refuses spawn or attach of THAT alias until its terminal
757
+ // attempt returns.
720
758
  const startupReconcile = this.auth && !this.userMode ? this.reconcileStaticLifecycles() : undefined;
721
759
  if (startupReconcile)
722
760
  void startupReconcile.catch((e) => console.error(`! ${STARTUP_RECONCILING}: ${e.message} - no per-alias retry could be planned; a later manager start re-reads unfinished durable terminals`));
@@ -731,6 +769,7 @@ export class Manager {
731
769
  // every half-TTL: re-sign the daemon creds files for their EXISTING nkeys, request the explicit
732
770
  // `reloadCreds` adoption on the delivery-admin rail, and persist the audit record doctor renders.
733
771
  if (this.auth) {
772
+ await this.assertDaemonSharesSecretStore();
734
773
  await this.renewDaemonCreds();
735
774
  this.credRenewTimer = setInterval(() => { void this.renewDaemonCreds(); }, credRenewIntervalMs(STANDING_RENEWABLE_TTL_SEC));
736
775
  this.credRenewTimer.unref?.();
@@ -785,6 +824,37 @@ export class Manager {
785
824
  // manager records each agent's read ACL at spawn (`commitAcl`, in provisionAgent) so the daemon can
786
825
  // re-authorize it; that is the only Plane-3 state the manager touches, and it rides minting.
787
826
  }
827
+ /** Proof that this manager remints into the store the delivery daemon reloads from.
828
+ * Fingerprint-only `reloadCreds` is safe only after this. Two named, different stores
829
+ * refuse the pass. A daemon that is not bound yet is not a named store: start and
830
+ * remint still proceed (tests, delayed delivery), writing this manager's store. The
831
+ * challenge runs again before every remint, so a later daemon on a foreign store is
832
+ * refused then rather than certified by an earlier absence. A request timeout is not
833
+ * absence: a hung rail would skip the proof, so it fails closed. */
834
+ async assertDaemonSharesSecretStore() {
835
+ let reply;
836
+ try {
837
+ reply = await this.ep.requestDeliveryAdmin("reloadStoreIdentity", {}, 5_000);
838
+ }
839
+ catch (e) {
840
+ const msg = e.message;
841
+ if (isAbsentDeliveryAdmin(msg))
842
+ return "absent";
843
+ throw new Error(`could not challenge the delivery daemon's SecretStore: ${msg}`);
844
+ }
845
+ if (!reply.ok)
846
+ throw new Error(reply.error ?? "delivery daemon refused to name the SecretStore it reloads from");
847
+ let daemon;
848
+ try {
849
+ daemon = parseSecretStoreIdentity(reply.data);
850
+ }
851
+ catch (e) {
852
+ throw new Error(`delivery daemon named an unreadable SecretStore: ${e.message}`);
853
+ }
854
+ if (!sameSecretStoreIdentity(this.secretStoreIdentity, daemon))
855
+ throw new Error(divergentSecretStoreRefusal(this.secretStoreIdentity, daemon));
856
+ return "shared";
857
+ }
788
858
  /** One class-2 renewal pass (D5 slice 5): re-sign `.cotal/delivery.creds` + `.cotal/membership-rw.creds`
789
859
  * for their existing nkeys, then request the delivery daemon's EXPLICIT `reloadCreds` adoption on the
790
860
  * delivery-admin rail and persist the audit record (`.cotal/renewal.json`) that `cotal doctor auth`
@@ -796,6 +866,7 @@ export class Manager {
796
866
  if (!release)
797
867
  return;
798
868
  try {
869
+ await this.assertDaemonSharesSecretStore();
799
870
  // Re-sign through the manager's ONE store — the SAME store the delivery daemon reads
800
871
  // (`runDelivery(args, store)`), so a hosted remint writes the store the daemon renews from,
801
872
  // never a divergent one. Locally this is the workstation FS store (`.cotal/*.creds`).
@@ -1305,7 +1376,7 @@ export class Manager {
1305
1376
  ? (() => {
1306
1377
  if (!files?.creds)
1307
1378
  throw new Error(`managed agent ${a.name} is static-auth but its credential path was not recorded`);
1308
- return { mode: "static", id: principal.actor, lifecycleUid: a.lifecycleUid, credential: { kind: "file", path: files.creds, sha256: this.fileDigestOrEmpty(files.creds) } };
1379
+ return { mode: "static", id: principal.actor, lifecycleUid: a.lifecycleUid, credential: { kind: "file", path: files.creds, sha256: this.fileDigestOrEmpty(files.creds) }, ...(a.issued ? { issued: a.issued } : {}) };
1309
1380
  })()
1310
1381
  : { mode: "open", id: principal.actor, lifecycleUid: a.lifecycleUid };
1311
1382
  const dependencies = [a.launch.source.configPath];
@@ -1339,7 +1410,7 @@ export class Manager {
1339
1410
  startedAt: new Date(a.startedAt).toISOString(),
1340
1411
  };
1341
1412
  }
1342
- /** Tear down every managed agent's footprint on a graceful {@link stop} (#159 B2). A manager exit is
1413
+ /** Tear down every managed agent's footprint on an explicit destructive {@link stop}. A manager exit is
1343
1414
  * a mass agent-exit, and without this its agents' footprints (creds files + `dm_`/`dlv_` durables + ACL
1344
1415
  * rows) would orphan exactly as the per-agent exit path prevents. Hard-stop each child (an exit has no
1345
1416
  * time for the graceful grace window) and AWAIT its deprovision — bounded per agent (`withTimeout`) and
@@ -1372,6 +1443,29 @@ export class Manager {
1372
1443
  if (failures.length)
1373
1444
  throw new Error(`manager shutdown could not prove every seat exited: ${failures.join("; ")}`);
1374
1445
  }
1446
+ /** Drop only this manager's local custody handles. Validate the complete snapshot before releasing
1447
+ * any handle so a non-detachable runtime cannot leave a partially detached manager behind. */
1448
+ releaseManagedAgents() {
1449
+ const managed = [...this.agents.values()];
1450
+ const blocked = managed.filter((a) => a.handle.kind === "pty" && typeof a.handle.release !== "function");
1451
+ if (blocked.length) {
1452
+ return [
1453
+ `manager shutdown cannot detach ${blocked.map((a) => `${a.name} (${a.handle.kind})`).join(", ")}: runtime handle does not support release`,
1454
+ ];
1455
+ }
1456
+ const failures = [];
1457
+ for (const a of managed) {
1458
+ try {
1459
+ a.handle.release?.();
1460
+ a.suppressCleanup = true;
1461
+ this.agents.delete(a.name);
1462
+ }
1463
+ catch (e) {
1464
+ failures.push(`${a.name}: ${e.message}`);
1465
+ }
1466
+ }
1467
+ return failures;
1468
+ }
1375
1469
  async stopRetainedAgentsOnExit() {
1376
1470
  const managed = [...this.agents.values()];
1377
1471
  for (const a of managed)
@@ -1396,7 +1490,7 @@ export class Manager {
1396
1490
  if (failures.length)
1397
1491
  throw new Error(`manager preservation shutdown incomplete: ${failures.join("; ")}`);
1398
1492
  }
1399
- async stop() {
1493
+ async stop(options = {}) {
1400
1494
  this.staticReconcileStopping = true;
1401
1495
  const starting = this.startTask;
1402
1496
  for (const item of this.staticReconcileItems.values()) {
@@ -1413,8 +1507,12 @@ export class Manager {
1413
1507
  clearInterval(this.credRenewTimer);
1414
1508
  if (this.sessionKeyRenewTimer)
1415
1509
  clearInterval(this.sessionKeyRenewTimer);
1510
+ let releaseFailures = [];
1416
1511
  if (this.maintenanceState === "active" && !this.resumeRequired) {
1417
- await this.teardownManagedAgents(); // normal shutdown stays destructive (#159 B2)
1512
+ if (options.withAgents)
1513
+ await this.teardownManagedAgents();
1514
+ else
1515
+ releaseFailures = this.releaseManagedAgents();
1418
1516
  }
1419
1517
  else {
1420
1518
  // A signal after a partial preservation must never fall back into destructive teardown.
@@ -1437,6 +1535,8 @@ export class Manager {
1437
1535
  await this.stopSessionPlane();
1438
1536
  await this.ep.stop();
1439
1537
  await this.attach.stop();
1538
+ if (releaseFailures.length)
1539
+ throw new Error(`manager shutdown could not release every detachable seat: ${releaseFailures.join("; ")}`);
1440
1540
  }
1441
1541
  /**
1442
1542
  * REMOVE THIS INSTANCE'S SERVICE REGISTRATION on a clean stop (§13.5 deregistration).
@@ -1470,7 +1570,9 @@ export class Manager {
1470
1570
  },
1471
1571
  });
1472
1572
  try {
1473
- const outcome = await (this.auth ? this.withEndpointServeExecutor(dereg) : this.withOpenServeConnection(dereg));
1573
+ const outcome = await ((this.auth || this.remoteAuthority)
1574
+ ? this.withEndpointServeExecutor(dereg)
1575
+ : this.withOpenServeConnection(dereg));
1474
1576
  if (outcome.removed)
1475
1577
  console.error(`✓ deregistered manager instance ${iid} from the ${MANAGER_ENDPOINT} service registry (spec revision ${outcome.specRevision})`);
1476
1578
  else if (outcome.reason === "superseded")
@@ -1791,23 +1893,25 @@ export class Manager {
1791
1893
  * only into operator instruments (§13.2: `any` is operator-policy-mintable; the agent/spawn
1792
1894
  * rollups never carry them), so REACHING the handler is holding the admin tier, exactly as
1793
1895
  * holding `ctl.<admin>` is today. User mesh: the caller's CURRENT ledger scope must carry
1794
- * `admin` the same fresh-read authority {@link psOwnerFilter} consults, so a revoked scope
1795
- * demotes the very next call even on a still-valid bearer. Fail-closed: an unreadable ledger
1796
- * authorizes nothing. NAMED RESIDUAL (critic, 1c.2b): the static `true` has no serve-time
1896
+ * `admin`. A local-host manager reads its own ledger fresh. A remote-authority manager relays the
1897
+ * subject-parsed caller tuple to its host-owned authorization callback, with no participant-ledger
1898
+ * fallback. The same fresh authority drives {@link psOwnerFilter}, so a revoked scope demotes the
1899
+ * very next call even on a still-valid bearer. Local ledger read failures authorize nothing;
1900
+ * remote host-state faults throw and fail the operation closed. NAMED RESIDUAL (critic,
1901
+ * 1c.2b): the static `true` has no serve-time
1797
1902
  * re-check — a LEAKED static admin instrument keeps its reach until the credential's bounded
1798
1903
  * TTL (the one-shot 5-minute profile), the same static-revoke≠reconnect-death class ruled
1799
1904
  * across this campaign; static revocation is the TTL, not a ledger. */
1800
1905
  async epAdminReach(caller) {
1801
1906
  if (!this.userMode)
1802
1907
  return true;
1803
- const key = parsePrincipalKey(caller);
1804
- if (!key)
1805
- return false;
1908
+ if (this.remoteAuthority)
1909
+ return this.remoteAuthority.authorizeAdmin({ owner: caller.owner, actor: caller.actor, lifecycleUid: caller.uid });
1806
1910
  try {
1807
1911
  const scope = await resolveAuthProvider().actorScope({
1808
1912
  dir: userAuthStateDir(this.workspaceRoot, this.space),
1809
- owner: key.owner,
1810
- actor: key.actor,
1913
+ owner: caller.owner,
1914
+ actor: caller.actor,
1811
1915
  });
1812
1916
  return scope?.includes("admin") === true;
1813
1917
  }
@@ -1823,7 +1927,7 @@ export class Manager {
1823
1927
  async epAnyModeAdmin(ctx) {
1824
1928
  if (ctx.subject.target?.mode !== "any")
1825
1929
  return false;
1826
- if (!(await this.epAdminReach(principalKey(ctx.subject.caller.owner, ctx.subject.caller.actor).key)))
1930
+ if (!(await this.epAdminReach(ctx.subject.caller)))
1827
1931
  throw new EpEnvelopeError("permission-denied", `an any-mode ${ctx.subject.command} is operator reach; the caller's current ledger grant does not carry "admin" (SPEC 13.2)`);
1828
1932
  return true;
1829
1933
  }
@@ -1901,7 +2005,7 @@ export class Manager {
1901
2005
  // call instead of riding the bearer's remaining JWT-row lifetime. The resume family keeps its
1902
2006
  // serveGated BYPASS (those ops must run while the maintenance fence holds) but not the gate.
1903
2007
  const adminGated = async (ctx, fn) => {
1904
- if (!(await this.epAdminReach(callerOf(ctx))))
2008
+ if (!(await this.epAdminReach(ctx.subject.caller)))
1905
2009
  throw new EpEnvelopeError("permission-denied", `${ctx.subject.command} is operator reach; the caller's current ledger grant does not carry "admin" (SPEC 13.2)`);
1906
2010
  return fn();
1907
2011
  };
@@ -1914,10 +2018,10 @@ export class Manager {
1914
2018
  };
1915
2019
  return managerCommandDefs({
1916
2020
  status: (ctx) => this.serveGated(ctx, () => this.managerStatusData()),
1917
- ps: (ctx) => this.serveGated(ctx, async () => this.list(await this.psOwnerFilter(callerOf(ctx), false))),
2021
+ ps: (ctx) => this.serveGated(ctx, async () => this.list(await this.psOwnerFilter(ctx.subject.caller, false))),
1918
2022
  inspect: (ctx) => this.serveGated(ctx, async () => {
1919
2023
  const name = String(args(ctx).name ?? "").trim();
1920
- const row = this.list(await this.psOwnerFilter(callerOf(ctx), false)).find((x) => x.name === name);
2024
+ const row = this.list(await this.psOwnerFilter(ctx.subject.caller, false)).find((x) => x.name === name);
1921
2025
  if (!row) {
1922
2026
  // The live hit above stays entirely local. Only a miss widens into durable static state,
1923
2027
  // where `not-found` is honest only after the slot read itself succeeds and returns absent.
@@ -1957,14 +2061,14 @@ export class Manager {
1957
2061
  spawn: (ctx) => this.serveGated(ctx, () => this.serveSpawnGoal(ctx, (h) => this.opStart(args(ctx), callerOf(ctx), h))),
1958
2062
  despawn: (ctx) => this.serveGated(ctx, async () => {
1959
2063
  const a = targetAgent(ctx);
1960
- const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx));
2064
+ const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
1961
2065
  if (denied)
1962
2066
  throw new EpEnvelopeError("permission-denied", denied);
1963
2067
  return unwrap(this.despawnAuthorized(a, args(ctx).graceful !== false, true));
1964
2068
  }),
1965
2069
  attach: (ctx) => this.serveGated(ctx, async () => {
1966
2070
  const a = targetAgent(ctx);
1967
- const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx));
2071
+ const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
1968
2072
  if (denied)
1969
2073
  throw new EpEnvelopeError("permission-denied", denied);
1970
2074
  return unwrap(await this.attachAuthorized(a, ctx.subject.caller));
@@ -1974,7 +2078,7 @@ export class Manager {
1974
2078
  // place for one of them to quietly acquire a condition the other does not have.
1975
2079
  input: (ctx) => this.serveGated(ctx, async () => {
1976
2080
  const a = targetAgent(ctx);
1977
- const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx));
2081
+ const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
1978
2082
  if (denied)
1979
2083
  throw new EpEnvelopeError("permission-denied", denied);
1980
2084
  return this.inputAuthorized(a, args(ctx));
@@ -1984,7 +2088,7 @@ export class Manager {
1984
2088
  // reason `input` is (a shared policy, not a shared body).
1985
2089
  turn: (ctx) => this.serveGated(ctx, async () => {
1986
2090
  const a = targetAgent(ctx);
1987
- const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx));
2091
+ const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
1988
2092
  if (denied)
1989
2093
  throw new EpEnvelopeError("permission-denied", denied);
1990
2094
  return this.serveTurnGoal(ctx, a);
@@ -2025,7 +2129,7 @@ export class Manager {
2025
2129
  finalizeResume: (ctx) => adminGated(ctx, async () => unwrap(await this.opFinalizeResume(args(ctx)))),
2026
2130
  // The workflow-run family (SPEC 14.3): the manager hosts the driver. Reach is the broker's
2027
2131
  // (`run` capability / privileged instrument rows); the serve gate is the maintenance fence.
2028
- runStart: (ctx) => this.serveGated(ctx, () => this.runHost().start(args(ctx))),
2132
+ runStart: (ctx) => this.serveGated(ctx, () => this.runHost().start(ctx, args(ctx))),
2029
2133
  runResume: (ctx) => this.serveGated(ctx, () => this.runHost().resume(args(ctx))),
2030
2134
  runAnswer: (ctx) => this.serveGated(ctx, () => this.runHost().answer(args(ctx), this.runAnswerer(ctx))),
2031
2135
  runStatus: (ctx) => this.serveGated(ctx, () => this.runHost().status(args(ctx))),
@@ -2075,11 +2179,27 @@ export class Manager {
2075
2179
  * instruments). With admin=true (any-mode) any named target is allowed (operator). Otherwise
2076
2180
  * a named target is allowed if it's the caller's OWN child (`spawner == caller`) — and, on a
2077
2181
  * user mesh, if it runs under the CALLER'S OWNER (owner-domain) or the caller's ledger row
2078
- * holds `admin`, read fresh. The policy is the pure
2079
- * {@link authorizeNamedControl}; this wrapper only binds the manager's state (the mode flag +
2080
- * the provider-backed ledger read a build with no provider authorizes nothing extra,
2081
- * fail-closed via the policy's catch). Error string when denied, `undefined` when allowed. */
2082
- authorizeNamed(target, caller, admin) {
2182
+ * holds `admin`, read fresh. Local managers run the pure {@link authorizeNamedControl} policy
2183
+ * against their provider-backed ledger. Remote managers preserve own-child and owner-domain
2184
+ * decisions locally, then send only the residual cross-owner admin question through the registered
2185
+ * host callback with the full endpoint caller tuple. Error string when denied, `undefined` when
2186
+ * allowed; remote host-state faults throw rather than being collapsed into a denial. */
2187
+ async authorizeNamed(target, caller, admin, epCaller) {
2188
+ if (this.remoteAuthority && epCaller && !admin) {
2189
+ if (target.spawner === caller)
2190
+ return undefined;
2191
+ const principal = parsePrincipalKey(caller);
2192
+ if (principal && target.userOwner === principal.owner)
2193
+ return undefined;
2194
+ // Unlike the local pure-policy adapter below, host authority faults are not collapsed into an
2195
+ // ordinary denial. The registered remote manager must fail the operation closed and surface
2196
+ // unavailable/corrupt authoritative state rather than implying a healthy `authorized:false`.
2197
+ if (await this.epAdminReach(epCaller))
2198
+ return undefined;
2199
+ return (`not authorized: ${target.name} runs under another owner - your grant covers agents under your own owner; ` +
2200
+ `cross-owner stop/attach/input needs scope "admin" on your actor. Re-grant with "admin" ADDED to your current ` +
2201
+ `scope (the upsert replaces the list; see \`cotal actor list\`)`);
2202
+ }
2083
2203
  return authorizeNamedControl({
2084
2204
  target: { name: target.name, spawner: target.spawner, userOwner: target.userOwner },
2085
2205
  caller,
@@ -2407,14 +2527,17 @@ export class Manager {
2407
2527
  // static retirement the detached deprovision below drives) — the alias frees only when the
2408
2528
  // gate+head terminal completes, exactly the user-mode discipline. The wire principal is the
2409
2529
  // incarnation-unique nkey (F5-bind); owner is the dev owner.
2410
- this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid), lifecycleUid: a.lifecycleUid, owner: DEV_OWNER, actor: a.id, agentId: a.id, secretPaths: a.secretPaths, startedAt: Date.now() });
2530
+ this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid), lifecycleUid: a.lifecycleUid, owner: DEV_OWNER, actor: a.id, agentId: a.id, secretPaths: a.secretPaths, runtime: a.handle?.reference, startedAt: Date.now() });
2411
2531
  }
2412
2532
  // Auth mode: tear down the departed agent's minted broker footprint + creds file (#159 B2). The
2413
2533
  // process is already gone, so this must never block the slot free or throw into the caller — it runs
2414
2534
  // detached, and a failure is logged loudly (never swallowed), not retried. The `agents` guard above
2415
2535
  // makes this fire exactly once per agent across every free path (despawn / self-stop / reap / exit).
2536
+ // The departed agent's own custody reference rides the teardown: the exit that freed this slot
2537
+ // is usually the seat ending, but a stop that could not prove the process gone leaves one the
2538
+ // terminal must still address before it frees the alias.
2416
2539
  if (!a.suppressCleanup && (this.maintenanceState === "active" || acceptedBeforeFence))
2417
- this.trackDeprovision(a);
2540
+ this.trackDeprovision({ ...a, runtime: a.handle?.reference });
2418
2541
  }
2419
2542
  /** Tear down a departed agent's minted footprint (#159 B2, auth mode): its local-principal durables
2420
2543
  * (`dm_local-<id>`, `dlv_local-<id>`), its read-ACL row, and its creds file — everything the spawn's
@@ -2429,7 +2552,7 @@ export class Manager {
2429
2552
  * is the separate per-user-auth work, not this. Tearing down the durables + ACL row still shrinks the
2430
2553
  * delivery surface a stale copy could use. */
2431
2554
  async deprovision(a) {
2432
- if (!this.auth)
2555
+ if (!this.auth && !this.remoteAuthority)
2433
2556
  return; // open mesh mints no creds/durables — nothing to tear down
2434
2557
  // SINGLE-FLIGHT per (name, lifecycleUid) (INT-2/C): join an in-flight teardown for this exact
2435
2558
  // lifecycle rather than launching a second concurrent one whose delayed name-keyed revoke could
@@ -2447,6 +2570,20 @@ export class Manager {
2447
2570
  }
2448
2571
  /** The actual footprint teardown (wrapped by {@link deprovision}'s single-flight). */
2449
2572
  async driveDeprovision(a) {
2573
+ if (this.remoteAuthority) {
2574
+ // A hosted composition owns grant revocation and resumable footprint release. It calls this
2575
+ // prerequisite before the terminal rail. The callback must preserve this UID and its pending
2576
+ // state until the host decides terminal retirement, never map deprovisioning to suspension.
2577
+ const target = parsePrincipalKey(a.id);
2578
+ if (!target)
2579
+ throw new Error(`hosted retirement cannot derive the managed principal ${a.id}`);
2580
+ await this.remoteAuthority.prepareAgentRetirement({
2581
+ target: { owner: target.owner, actor: target.actor, lifecycleUid: a.lifecycleUid },
2582
+ opId: retireOpId(a.lifecycleUid),
2583
+ });
2584
+ await this.requestRetirement(a);
2585
+ return;
2586
+ }
2450
2587
  if (!this.auth)
2451
2588
  return; // guaranteed by deprovision; re-checked for the deprovisionBroker narrowing
2452
2589
  if (!this.userMode && !a.userOwner) {
@@ -2552,8 +2689,8 @@ export class Manager {
2552
2689
  }
2553
2690
  /** The rail round-trip for one retirement (wrapped by {@link requestRetirement}'s single-flight). */
2554
2691
  async driveRetirement(a) {
2555
- if (!this.auth)
2556
- return; // guaranteed by requestRetirement; re-checked for the type narrowing below
2692
+ if (!this.auth && !this.remoteAuthority)
2693
+ return;
2557
2694
  const held = this.retiring.get(a.name);
2558
2695
  const target = parsePrincipalKey(a.id);
2559
2696
  if (!target) {
@@ -2594,10 +2731,18 @@ export class Manager {
2594
2731
  // mismatch in the owner half the moment a manager ran under a user-shaped identity. This is
2595
2732
  // also the more honest attribution: the authority being exercised is "I am the registered
2596
2733
  // serving instance", which is exactly what the gate records.
2597
- const caller = { owner: DEV_OWNER, actor: serveIdentity.id, uid: this.managerLifecycleUid };
2598
- const creds = await mintCreds(this.auth, newIdentity(), "retirement-requester", {
2599
- retirementRequester: { ...caller, target: { owner: target.owner, actor: target.actor, lifecycleUid: a.lifecycleUid } },
2600
- });
2734
+ const caller = this.remoteAuthority
2735
+ ? { owner: this.remoteAuthority.owner, actor: this.remoteAuthority.actors.serve, uid: this.managerLifecycleUid }
2736
+ : { owner: DEV_OWNER, actor: serveIdentity.id, uid: this.managerLifecycleUid };
2737
+ const requestIdentity = newIdentity();
2738
+ const retirementTarget = { owner: target.owner, actor: target.actor, lifecycleUid: a.lifecycleUid };
2739
+ const opId = retireOpId(a.lifecycleUid);
2740
+ const serveEpoch = this.serviceServe?.grant.epoch ?? 0;
2741
+ const creds = this.remoteAuthority
2742
+ ? await this.remoteAuthority.mintRetirementRequester({ identity: requestIdentity, target: retirementTarget, opId, serveEpoch })
2743
+ : await mintCreds(this.auth, requestIdentity, "retirement-requester", {
2744
+ retirementRequester: { ...caller, target: retirementTarget },
2745
+ });
2601
2746
  const nc = await this.dial({ authenticator: credsAuthenticator(new TextEncoder().encode(creds)), maxReconnectAttempts: 0 });
2602
2747
  try {
2603
2748
  // §13.2 nonce: >=128 bits of CSPRNG entropy, base64url (the `endpoint-invoke` idiom).
@@ -2625,30 +2770,12 @@ export class Manager {
2625
2770
  // caller's own subject-derived principal. A superseded predecessor (same instanceId, OLD
2626
2771
  // epoch after a restart) is still refused by the epoch comparison.
2627
2772
  JSON.stringify({ id: requestId, op: "retireLifecycle", args: {
2628
- opId: retireOpId(a.lifecycleUid),
2629
- serveEndpoint: MANAGER_ENDPOINT, serveInstanceId: this.managerInstanceId, serveEpoch: this.serviceServe?.grant.epoch ?? 0,
2773
+ opId,
2774
+ serveEndpoint: MANAGER_ENDPOINT, serveInstanceId: this.managerInstanceId, serveEpoch,
2630
2775
  } }), 20_000);
2631
2776
  const r = m;
2632
2777
  if (r.ok) {
2633
- // CAS the hold clear (audit #1 ABA): free the alias ONLY if the current hold is still THIS
2634
- // lifecycle's - a late reply for a retired predecessor must never clear a successor's newer hold.
2635
- const cur = this.retiring.get(a.name);
2636
- if (cur && cur.lifecycleUid === a.lifecycleUid) {
2637
- if (cur.standingAuthorityLive) {
2638
- // INT-2: the auth-plane lifecycle retired, but the manager-side STANDING mint authority is
2639
- // not yet revoked (a failed revoke). Freeing the name here would be a false terminal (a
2640
- // copied token could still mint), so keep the hold with its revoke-failure copy; a retry
2641
- // re-drives the full teardown (revoke included).
2642
- console.error(`despawn ${a.name}: the auth-plane lifecycle retired, but the standing mint authority is not yet revoked; the name stays held. ${cur.lastError ?? ""}`);
2643
- }
2644
- else {
2645
- this.retiring.delete(a.name);
2646
- console.error(`despawn ${a.name}: the agent's retirement completed; the name is free for reuse`);
2647
- }
2648
- }
2649
- else {
2650
- console.error(`despawn ${a.name}: retirement confirmed for a prior lifecycle of "${a.name}"; the current hold is left intact`);
2651
- }
2778
+ this.confirmRetirement(a);
2652
2779
  }
2653
2780
  else {
2654
2781
  // The rail's refusal is already the operator copy (lease-loss/stale/foreign-op faces,
@@ -2669,6 +2796,30 @@ export class Manager {
2669
2796
  console.error(`despawn ${a.name}: ${copy}`);
2670
2797
  }
2671
2798
  }
2799
+ /** Apply one terminal retirement answer. The predecessor coordinate is recorded only when the
2800
+ * answer clears this exact lifecycle's hold, never for an ABA-late answer or an incomplete hosted
2801
+ * teardown whose standing authority still lives. */
2802
+ confirmRetirement(a) {
2803
+ // CAS the hold clear (audit #1 ABA): free the alias ONLY if the current hold is still THIS
2804
+ // lifecycle's. A late reply for a retired predecessor must never clear a successor's newer hold.
2805
+ const cur = this.retiring.get(a.name);
2806
+ if (cur && cur.lifecycleUid === a.lifecycleUid) {
2807
+ if (cur.standingAuthorityLive) {
2808
+ // INT-2: the auth-plane lifecycle retired, but the manager-side STANDING mint authority is
2809
+ // not yet revoked. A copied token could still mint, so neither free the alias nor classify
2810
+ // its presence as a completed predecessor.
2811
+ console.error(`despawn ${a.name}: the auth-plane lifecycle retired, but the standing mint authority is not yet revoked; the name stays held. ${cur.lastError ?? ""}`);
2812
+ }
2813
+ else {
2814
+ this.confirmedRetiredPredecessors.set(a.name, { principal: a.id, lifecycleUid: a.lifecycleUid });
2815
+ this.retiring.delete(a.name);
2816
+ console.error(`despawn ${a.name}: the agent's retirement completed; the name is free for reuse`);
2817
+ }
2818
+ }
2819
+ else {
2820
+ console.error(`despawn ${a.name}: retirement confirmed for a prior lifecycle of "${a.name}"; the current hold is left intact`);
2821
+ }
2822
+ }
2672
2823
  /** The teardown's ASYNC BROKER PHASE: mint the ephemeral target-pinned deprovisioner cred and
2673
2824
  * delete the agent's broker footprint (dm_/dlv_ durables + read-ACL row). Split from
2674
2825
  * {@link deprovision} because it runs LAST in the ordered teardown chain — after the creds/secret
@@ -2831,7 +2982,12 @@ export class Manager {
2831
2982
  const joinedAfter = this.ep.getRoster()
2832
2983
  .filter((p) => p.card.id === wanted && p.lifecycleUid === a.lifecycleUid)
2833
2984
  .reduce((max, p) => Math.max(max, p.ts), 0) + 1;
2834
- const handle = this.runtime.spawn(a.name, spec, a.launch.cwd);
2985
+ // Same ordering as the spawn path: the replacement seat's reference is recorded on the
2986
+ // still-active slot before its processes exist, so a crash here never strands it.
2987
+ const custody = this.reserveCustody();
2988
+ if (custody)
2989
+ await this.recordSlotCustody(a, custody);
2990
+ const handle = await this.spawnCustodied(a.name, spec, a.launch.cwd, custody);
2835
2991
  replacement = handle;
2836
2992
  restart.sessionStatePath = spec.sessionStatePath ?? restart.sessionStatePath;
2837
2993
  if (continueSession !== undefined)
@@ -2867,6 +3023,7 @@ export class Manager {
2867
3023
  replacement = undefined;
2868
3024
  restart.opts = opts;
2869
3025
  restart.recovering = false;
3026
+ await this.recordSlotRuntime(a);
2870
3027
  if (continueSession !== undefined)
2871
3028
  console.error(`! ${a.name}: recovered Pi session ${continueSession} after crash (${restart.crashes.length}/${limit})`);
2872
3029
  else
@@ -3177,14 +3334,13 @@ export class Manager {
3177
3334
  async psOwnerFilter(caller, admin) {
3178
3335
  if (!this.userMode || admin)
3179
3336
  return undefined;
3180
- const key = parsePrincipalKey(caller);
3181
- if (!key)
3182
- return NO_OWNER_MATCHES;
3337
+ if (this.remoteAuthority)
3338
+ return (await this.epAdminReach(caller)) ? undefined : caller.owner;
3183
3339
  try {
3184
3340
  const scope = await resolveAuthProvider().actorScope({
3185
3341
  dir: userAuthStateDir(this.workspaceRoot, this.space),
3186
- owner: key.owner,
3187
- actor: key.actor,
3342
+ owner: caller.owner,
3343
+ actor: caller.actor,
3188
3344
  });
3189
3345
  if (scope?.includes("admin"))
3190
3346
  return undefined;
@@ -3192,7 +3348,7 @@ export class Manager {
3192
3348
  catch {
3193
3349
  /* unreadable ledger authorizes nothing extra: fall through to the own-owner bound */
3194
3350
  }
3195
- return key.owner;
3351
+ return caller.owner;
3196
3352
  }
3197
3353
  /** Boot one resolved agent from a mesh-manifest launch spec, for `cotal spawn -f` onto a RUNNING
3198
3354
  * manager. The request carries `{ runId, name }` — plus, for a deploy from another checkout or
@@ -3554,6 +3710,11 @@ export class Manager {
3554
3710
  // AFTER provisioning (buildLaunch / runtime.spawn) — the orphan-rollback tears it down. Carries
3555
3711
  // `userOwner` for a user-mode spawn so that rollback runs the revoke+shred branch, not just the
3556
3712
  // static durable teardown (the freelance found this window leaking the managed grant + files).
3713
+ // The RESERVED custody reference rides the rollback object: a spawn that launched the seat and
3714
+ // then threw (the activation CAS refuses a slot that is not this spawn's intent) reaches the
3715
+ // `finally` with the handle only in a local, so without this the terminal has nothing to reap
3716
+ // and retires over a live process. The slot row holds the same reference; this is the copy the
3717
+ // in-process rollback can actually read.
3557
3718
  let provisioned;
3558
3719
  try {
3559
3720
  // A stable nkey identity assigned at spawn: the public key is the agent's card.id (threaded via
@@ -3563,6 +3724,11 @@ export class Manager {
3563
3724
  // broker resource (dm_/dlv_/chathist_ durables, ACL row, memberships) and the teardown
3564
3725
  // credential carry it, so a same-name successor's footprint is name-disjoint by construction.
3565
3726
  const lifecycleUid = mintLifecycleUid();
3727
+ // Reserve the seat's custody reference NOW, before any durable row and long before the
3728
+ // launch. Minting it inside `runtime.spawn` (as it used to be) put the processes on disk
3729
+ // before anything recorded how to address them, so a crash in that window left a live seat
3730
+ // outside every manager and the successor retired the slot over it.
3731
+ const custody = this.reserveCustody();
3566
3732
  // ACCEPT SEAM (P2 item 2 spawn-as-action): the incarnation identity is minted and NOTHING has
3567
3733
  // been provisioned yet — the action serve path binds the goal + replies the acceptance HERE. A
3568
3734
  // throw (bind conflict / duplicate goalId) aborts the spawn before provisioning: the catch below
@@ -3625,6 +3791,7 @@ export class Manager {
3625
3791
  // spawned session reads them (COTAL_CREDS path). Open mesh → no creds. Scope = the resolved
3626
3792
  // subscribe/allowSubscribe (read) + allowPublish (post, default-deny).
3627
3793
  let credsPath;
3794
+ let issued;
3628
3795
  let userLaunch;
3629
3796
  let userOwner;
3630
3797
  if (this.userMode) {
@@ -3645,7 +3812,7 @@ export class Manager {
3645
3812
  }
3646
3813
  userLaunch = prep.launch;
3647
3814
  userOwner = prep.owner;
3648
- provisioned = { id: principalKey(prep.owner, name).key, name, lifecycleUid, userOwner: prep.owner, secretPaths: prep.files };
3815
+ provisioned = { id: principalKey(prep.owner, name).key, name, lifecycleUid, userOwner: prep.owner, secretPaths: prep.files, ...(custody ? { runtime: custody } : {}) };
3649
3816
  }
3650
3817
  else if (this.auth) {
3651
3818
  // Unit B (§13.1): reserve + activate this incarnation's DURABLE identity BEFORE any
@@ -3653,18 +3820,25 @@ export class Manager {
3653
3820
  // then the SHARED core activation saga (reserve uid -> gate frozen -> head CAS -> reopen
3654
3821
  // LAST) over the key-pinned executor. The wire AUTHORITY principal is the incarnation-
3655
3822
  // unique nkey (F5-bind); the alias is protected by the name-keyed slot + freeSlot hold.
3656
- await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: identity.id, lifecycleUid, alias: name }, (t) => activateStaticLifecycle(t, { owner: DEV_OWNER, alias: name, actor: identity.id, lifecycleUid, managerInstance: this.managerLifecycleUid, ownerInstanceId: this.managerInstanceId }));
3823
+ await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: identity.id, lifecycleUid, alias: name }, (t) => activateStaticLifecycle(t, { owner: DEV_OWNER, alias: name, actor: identity.id, lifecycleUid, managerInstance: this.managerLifecycleUid, ownerInstanceId: this.managerInstanceId, runtime: custody }));
3657
3824
  // From here the DURABLE registration exists: arm the rollback BEFORE minting, so a throw
3658
3825
  // between activation and provisioning still drives the exact-op static terminal (the
3659
3826
  // finally's deprovision tolerates absent files; the broker teardown is idempotent).
3660
- provisioned = { id: identity.id, name, lifecycleUid };
3827
+ provisioned = { id: identity.id, name, lifecycleUid, ...(custody ? { runtime: custody } : {}) };
3661
3828
  // Pre-create the agent's bind-only chat (+ DM + role TASK) durables and mint its scoped creds
3662
3829
  // — the shared onboarding step (provisionAgent). It runs on a short-lived PROVISIONER connection
3663
3830
  // (NOT the supervisor's long-lived endpoint), so the DM/DLV consumer-create surface exists only
3664
3831
  // for the provisioning window, never as a standing grant on the always-on daemon (residual 2).
3665
3832
  // F5(b): the credential is BOUNDED (`expiresAt`) — the manager push-renews it ahead of expiry.
3666
3833
  const exp = Math.floor(Date.now() / 1000) + MANAGED_STATIC_TTL_SEC;
3667
- const creds = await this.withProvisioner((prov) => provisionAgent(prov, this.auth, identity, {
3834
+ // The credential is an ISSUANCE (SPEC 13.15): its caller rails carry a fresh generation
3835
+ // and its ceiling is recorded as evidence bound to this incarnation's ledger family. The
3836
+ // §13.1 ledger append (ledger BEFORE materialization) is the release's finalizer, so the
3837
+ // evidence activates only once the row exists, and the row exists only after the evidence
3838
+ // was staged. The agent learns the generation by reading the accepted row under its token.
3839
+ issued = { generation: mintGeneration(), acceptedToken: mintAcceptedToken() };
3840
+ const issuedAgent = { name, id: identity.id, lifecycleUid };
3841
+ const creds = await this.withIssuer((i) => this.withProvisioner((prov) => provisionAgent(prov, this.auth, identity, {
3668
3842
  subscribe,
3669
3843
  allowSubscribe,
3670
3844
  allowPublish,
@@ -3672,15 +3846,9 @@ export class Manager {
3672
3846
  capabilities,
3673
3847
  lifecycleUid,
3674
3848
  expiresAt: exp,
3675
- }));
3676
- // Ledger BEFORE materialization (§13.1): record the credentialId on the slot, append the
3677
- // `cred.<uid>.<credId>` row, and only then write the credential where anything can read
3678
- // it — a credential is never materialized before its ledger row exists.
3679
- const credentialId = rawDigest(creds).replace("sha256:", "sha256-");
3680
- await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: identity.id, lifecycleUid, alias: name }, async (t) => {
3681
- await recordSlotCredential(t, DEV_OWNER, name, lifecycleUid, credentialId);
3682
- await appendStaticCredentialRow(t, { lifecycleUid, credentialId, holderPrincipal: principalKey(DEV_OWNER, identity.id).key, exp });
3683
- });
3849
+ issued,
3850
+ issuance: this.issuanceSeam(i, "issue", issuedAgent, exp),
3851
+ })));
3684
3852
  // Store first (the source of truth), then materialize: `buildLaunch` hands the CHILD this
3685
3853
  // file path, so the cred must exist as a file regardless of the store behind the seam. The
3686
3854
  // manager's ONE store (injected for hosted, workstation FS locally).
@@ -3690,7 +3858,7 @@ export class Manager {
3690
3858
  credsPath = agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, name, lifecycleUid).creds;
3691
3859
  await secrets.put(agentSecretKeyForFile(credsPath, this.space), creds);
3692
3860
  await materializeSecretToFile(secrets, agentSecretKeyForFile(credsPath, this.space), credsPath);
3693
- provisioned = { id: identity.id, name, lifecycleUid, secretPaths: { creds: credsPath } }; // footprint now exists — the finally rolls it back if the spawn throws
3861
+ provisioned = { id: identity.id, name, lifecycleUid, secretPaths: { creds: credsPath }, ...(custody ? { runtime: custody } : {}) }; // footprint now exists — the finally rolls it back if the spawn throws
3694
3862
  }
3695
3863
  // Personal MCP servers the operator opted to share with manager-spawned agents of this type
3696
3864
  // (cotal config; default none → isolated, the memory-safe default this guards), narrowed by
@@ -3723,6 +3891,7 @@ export class Manager {
3723
3891
  // chathist durables by this exact value (its creds pin the same names, so a mismatch fails
3724
3892
  // at the broker, never silently).
3725
3893
  lifecycleUid,
3894
+ acceptedToken: issued?.acceptedToken,
3726
3895
  servers: this.servers,
3727
3896
  configPath,
3728
3897
  model,
@@ -3751,7 +3920,7 @@ export class Manager {
3751
3920
  workspaceRoot: this.workspaceRoot,
3752
3921
  };
3753
3922
  const spec = connector.buildLaunch(launchOpts);
3754
- const handle = this.runtime.spawn(name, spec, cwd);
3923
+ const handle = await this.spawnCustodied(name, spec, cwd, custody);
3755
3924
  hooks?.onLaunched?.(); // P2 item 2: the "launched" progress edge (process spawned, pre-presence)
3756
3925
  const managed = {
3757
3926
  name,
@@ -3763,6 +3932,7 @@ export class Manager {
3763
3932
  // recorded truth teardown/preservation/health consume, never re-derived by name.
3764
3933
  secretPaths: provisioned?.secretPaths,
3765
3934
  ...(userLaunch ? { userOwner } : { seed: identity.seed }),
3935
+ ...(issued ? { issued } : {}),
3766
3936
  spawner: spawner ?? this.ep.ref().id,
3767
3937
  authorityParent: userLaunch && spawner && parsePrincipalKey(spawner) ? spawner : undefined,
3768
3938
  startedAt: Date.now(),
@@ -3818,7 +3988,9 @@ export class Manager {
3818
3988
  const slot = await readStaticSlot(t, DEV_OWNER, name);
3819
3989
  if (slot === undefined || slot.row.lifecycleUid !== lifecycleUid || slot.row.phase !== "provisioning")
3820
3990
  throw new Error(`the static slot for "${name}" is ${slot === undefined ? "absent" : `${slot.row.phase} at uid ${slot.row.lifecycleUid}`}, not this spawn's provisioning intent; refusing to take the slot`);
3821
- await casStaticSlot(t, { ...slot.row, phase: "active" }, slot.revision);
3991
+ // The custody reference rides the SAME CAS as activation: a crash after this write leaves
3992
+ // an active slot a successor can reap by reference, never a live seat nobody addresses.
3993
+ await casStaticSlot(t, { ...slot.row, phase: "active", ...(handle.reference ? { runtime: handle.reference } : {}) }, slot.revision);
3822
3994
  });
3823
3995
  }
3824
3996
  this.agents.set(name, managed);
@@ -3915,15 +4087,33 @@ export class Manager {
3915
4087
  const seen = new Set();
3916
4088
  const principals = new Set();
3917
4089
  await this.ep.waitForPresenceSnapshot();
3918
- const livePrincipals = new Set(this.ep.getRoster()
3919
- .filter((presence) => presence.status !== "offline")
3920
- .map((presence) => presence.card.id));
4090
+ const liveRoster = this.ep.getRoster().filter((presence) => presence.status !== "offline");
3921
4091
  if (this.agents.size + this.reserved.size + this.coolingCount() + inventory.agents.length > MAX_AGENTS)
3922
4092
  return { ok: false, agents: [], error: `resume inventory would exceed manager capacity (${MAX_AGENTS})` };
3923
4093
  for (const entry of inventory.agents) {
3924
4094
  if (seen.has(entry.name))
3925
4095
  return { ok: false, agents: [], error: `resume inventory contains duplicate agent name "${entry.name}"` };
3926
4096
  seen.add(entry.name);
4097
+ // A manager-local retirement hold is stronger and more precise than presence. The stopped
4098
+ // predecessor can remain roster-live until its TTL/offline update lands, but that stale row
4099
+ // must not hide the durable teardown state the manager already owns. Refuse and re-drive the
4100
+ // exact held lifecycle before consulting generic principal liveness. A confirmed terminal has
4101
+ // already removed the hold, so fresh-lifecycle resume still reaches the fail-closed roster gate.
4102
+ const held = this.retiring.get(entry.name);
4103
+ if (held) {
4104
+ void this.deprovision({
4105
+ id: held.agentId,
4106
+ name: entry.name,
4107
+ lifecycleUid: held.lifecycleUid,
4108
+ userOwner: held.userOwner,
4109
+ secretPaths: held.secretPaths,
4110
+ }).catch(() => { });
4111
+ return {
4112
+ ok: false,
4113
+ agents: [],
4114
+ error: `retained agent "${entry.name}" is reserved pending retirement: its previous lifecycle ${held.lifecycleUid} still owns the alias${held.lastError ? ` (last attempt: ${held.lastError})` : ""}; retrying re-drives that exact teardown`,
4115
+ };
4116
+ }
3927
4117
  let principal;
3928
4118
  try {
3929
4119
  principal = entry.identity.mode === "user"
@@ -3936,7 +4126,17 @@ export class Manager {
3936
4126
  if (principals.has(principal))
3937
4127
  return { ok: false, agents: [], error: `resume inventory contains duplicate principal "${principal}"` };
3938
4128
  principals.add(principal);
3939
- if (livePrincipals.has(principal))
4129
+ const confirmedPredecessor = this.confirmedRetiredPredecessors.get(entry.name);
4130
+ const principalIsLive = liveRoster.some((presence) => {
4131
+ if (presence.card.id !== principal)
4132
+ return false;
4133
+ return confirmedPredecessor === undefined ||
4134
+ confirmedPredecessor.principal !== principal ||
4135
+ confirmedPredecessor.lifecycleUid !== presence.lifecycleUid ||
4136
+ presence.card.name !== entry.name ||
4137
+ entry.identity.lifecycleUid === confirmedPredecessor.lifecycleUid;
4138
+ });
4139
+ if (principalIsLive)
3940
4140
  return { ok: false, agents: [], error: `retained principal "${principal}" is already live and this runtime cannot authoritatively adopt it` };
3941
4141
  if (this.agents.has(entry.name) || this.reserved.has(entry.name))
3942
4142
  return { ok: false, agents: [], error: `retained agent "${entry.name}" is already managed or reserved` };
@@ -4088,15 +4288,23 @@ export class Manager {
4088
4288
  const sentinelCreds = await secrets.get(agentSecretKeyForFile(recordedSentinel, this.space));
4089
4289
  if (actorToken === undefined || sentinelCreds === undefined)
4090
4290
  throw new Error("the retained actor token / sentinel credential is not in the secret store");
4091
- const adopted = await provider.validateRetainedAgent({
4092
- store: secrets,
4093
- dir: userAuthStateDir(this.workspaceRoot, this.space),
4094
- space: this.space,
4095
- owner: entry.identity.owner,
4096
- actor: entry.identity.actor,
4097
- actorToken,
4098
- sentinelCreds,
4099
- });
4291
+ const adopted = this.remoteAuthority
4292
+ ? await this.remoteAuthority.validateRetainedAgent({
4293
+ owner: entry.identity.owner,
4294
+ actor: entry.identity.actor,
4295
+ lifecycleUid: entry.identity.lifecycleUid,
4296
+ actorToken,
4297
+ sentinelCreds,
4298
+ })
4299
+ : await provider.validateRetainedAgent({
4300
+ store: secrets,
4301
+ dir: userAuthStateDir(this.workspaceRoot, this.space),
4302
+ space: this.space,
4303
+ owner: entry.identity.owner,
4304
+ actor: entry.identity.actor,
4305
+ actorToken,
4306
+ sentinelCreds,
4307
+ });
4100
4308
  if (adopted.owner !== entry.identity.owner || adopted.actor !== entry.identity.actor)
4101
4309
  throw new Error(`auth provider returned a replacement principal; expected ${entry.identity.owner}.${entry.identity.actor}`);
4102
4310
  // Bind the inventory's uid to the CURRENT authority row BEFORE any spawn: a corrupt or
@@ -4119,7 +4327,9 @@ export class Manager {
4119
4327
  ...process.execArgv,
4120
4328
  process.argv[1],
4121
4329
  provider.agentBearerCommand,
4122
- "--dir", userAuthStateDir(this.workspaceRoot, this.space),
4330
+ ...(this.remoteAuthority
4331
+ ? ["--exchange-url", this.remoteAuthority.agentBearerExchangeUrl]
4332
+ : ["--dir", userAuthStateDir(this.workspaceRoot, this.space)]),
4123
4333
  "--space", this.space,
4124
4334
  "--owner", entry.identity.owner,
4125
4335
  "--actor", entry.identity.actor,
@@ -4251,6 +4461,7 @@ export class Manager {
4251
4461
  // with no COTAL_LIFECYCLE_UID: static/user fail the connector auth gate and open self-mints a
4252
4462
  // fresh uid that orphans the preserved durables and never matches the readiness fence.
4253
4463
  lifecycleUid: entry.identity.lifecycleUid,
4464
+ acceptedToken: entry.identity.mode === "static" ? entry.identity.issued?.acceptedToken : undefined,
4254
4465
  servers: this.servers,
4255
4466
  configPath: entry.launch.source.configPath,
4256
4467
  model: entry.launch.model,
@@ -4300,13 +4511,18 @@ export class Manager {
4300
4511
  const readinessTimeoutMs = connector.readinessTimeoutMs ?? this.readinessTimeoutMs;
4301
4512
  if (!Number.isSafeInteger(readinessTimeoutMs) || readinessTimeoutMs <= 0)
4302
4513
  return { ok: false, error: `connector ${connector.name} declares invalid readinessTimeoutMs ${JSON.stringify(connector.readinessTimeoutMs)}; expected a positive safe integer` };
4303
- const handle = this.runtime.spawn(entry.name, prepared.spec, entry.launch.cwd);
4514
+ const resumeId = entry.identity.mode === "user" ? principalKey(entry.identity.owner, entry.identity.actor).key : entry.identity.id;
4515
+ const custody = this.reserveCustody();
4516
+ if (custody)
4517
+ await this.recordSlotCustody({ name: entry.name, id: resumeId, lifecycleUid: entry.identity.lifecycleUid }, custody);
4518
+ const handle = await this.spawnCustodied(entry.name, prepared.spec, entry.launch.cwd, custody);
4304
4519
  const managed = {
4305
4520
  name: entry.name,
4306
4521
  role: entry.role,
4307
4522
  agent: entry.launch.connector,
4308
- id: entry.identity.mode === "user" ? principalKey(entry.identity.owner, entry.identity.actor).key : entry.identity.id,
4523
+ id: resumeId,
4309
4524
  seed: adoptedSeed,
4525
+ ...(entry.identity.mode === "static" && entry.identity.issued ? { issued: entry.identity.issued } : {}),
4310
4526
  // Recover the ORIGINAL incarnation uid the durables are keyed by (never a fresh mint on resume).
4311
4527
  lifecycleUid: entry.identity.lifecycleUid,
4312
4528
  // Adopt the INVENTORY's recorded family (possibly a pre-split name-keyed layout) — the
@@ -4345,6 +4561,7 @@ export class Manager {
4345
4561
  this.agents.set(entry.name, managed);
4346
4562
  if (this.resumeAttemptId)
4347
4563
  this.resumedAgentNames.add(entry.name);
4564
+ await this.recordSlotRuntime(managed);
4348
4565
  const readiness = await this.awaitReadiness(managed, readinessTimeoutMs);
4349
4566
  if (!readiness.ok && !readiness.uncertain)
4350
4567
  return { ok: false, error: readiness.detail };
@@ -4643,16 +4860,56 @@ export class Manager {
4643
4860
  await nc.drain().catch(() => nc.close());
4644
4861
  }
4645
4862
  }
4646
- /** Run one §13.1 ENDPOINT-SERVE credential operation (P2 item 1, 1a-serve) over an ephemeral,
4863
+ /** Run one SPEC 13.15 issuance operation over an ephemeral `issuer` connection: the evidence
4864
+ * stage/release of one mint, or the retirement walk of one lifecycle's issuances. The store
4865
+ * handle and the accepted-row KV live only for this window; the standing supervisor holds no
4866
+ * grant on either bucket. */
4867
+ async withIssuer(fn) {
4868
+ if (!this.auth)
4869
+ throw new Error("withIssuer: no space auth (an open mesh issues nothing)");
4870
+ const creds = await mintCreds(this.auth, newIdentity(), "issuer");
4871
+ const nc = await this.dial({ ...standaloneConnectOpts({ creds, /* not yet wired to a recorded transport */ tls: false }), maxReconnectAttempts: 0 });
4872
+ try {
4873
+ const kvm = new Kvm(nc);
4874
+ const store = openIssuedStore(await kvm.open(issuedBucket(this.space)), await jetstreamManager(nc), this.space);
4875
+ return await fn({ store, accepted: await kvm.open(acceptedBucket(this.space)) });
4876
+ }
4877
+ finally {
4878
+ await nc.drain().catch(() => nc.close());
4879
+ }
4880
+ }
4881
+ /** The issuance seam of ONE static credential mint (SPEC 13.15): the evidence is bound to the
4882
+ * incarnation's own `cred.<uid>` ledger family, so the family's revocation (the static
4883
+ * terminal) retires every issuance that named it. The finalizer is the §13.1 ledger append
4884
+ * (slot credentialId + `cred.<uid>.<credId>` row); release runs it, then activates. */
4885
+ issuanceSeam(i, mode, a, exp) {
4886
+ return {
4887
+ mode,
4888
+ store: i.store,
4889
+ accepted: i.accepted,
4890
+ sources: mode === "issue" ? [{ space: this.space, bucket: epAuthBucket(this.space), key: `cred.${a.lifecycleUid}` }] : [],
4891
+ finalize: async (credentialId) => {
4892
+ await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: a.id, lifecycleUid: a.lifecycleUid, alias: a.name }, async (t) => {
4893
+ await recordSlotCredential(t, DEV_OWNER, a.name, a.lifecycleUid, credentialId);
4894
+ await appendStaticCredentialRow(t, { lifecycleUid: a.lifecycleUid, credentialId, holderPrincipal: principalKey(DEV_OWNER, a.id).key, exp });
4895
+ });
4896
+ },
4897
+ };
4898
+ }
4899
+ /** Run one §13.1 endpoint-instance maintenance operation over an ephemeral,
4647
4900
  * key-pinned `endpoint-serve-executor` connection: the credential's grants name exactly the
4648
4901
  * manager instance's `epgate`/`epcred` keys plus its registration's two records keys, so the
4649
- * gate CAS, the mint fence, and the spec/governance writes ride a one-shot scoped authority —
4650
- * NEVER the manager's standing seed/supervisor connection (the panel's "no seed shortcut"). */
4902
+ * gate CAS, mint fence, spec/governance writes, and clean deregistration ride a one-shot scoped
4903
+ * authority. The remote boot goal-index sweep instead uses the authenticated host-owned scan.
4904
+ * This is NEVER the manager's standing seed/supervisor connection. */
4651
4905
  async withEndpointServeExecutor(fn) {
4652
4906
  const identity = this.remoteAuthority?.identities.executor ?? newIdentity();
4653
4907
  const creds = this.remoteAuthority?.executorCreds ?? (this.auth
4654
4908
  ? await mintCreds(this.auth, identity, "endpoint-serve-executor", {
4655
4909
  endpointServeExecutor: { endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId },
4910
+ ...(this.endpointServeExecutorExpiresInSeconds !== undefined
4911
+ ? { expiresInSeconds: this.endpointServeExecutorExpiresInSeconds }
4912
+ : {}),
4656
4913
  })
4657
4914
  : undefined);
4658
4915
  if (!creds)
@@ -4671,8 +4928,8 @@ export class Manager {
4671
4928
  * writes ride a bare one-shot connection (the broker enforces nothing on an open mesh; the
4672
4929
  * ceremony still produces the real gate, epoch, and registration the serve rails run on). */
4673
4930
  async withOpenServeConnection(fn) {
4674
- if (this.auth)
4675
- throw new Error("withOpenServeConnection: an auth mesh must use the scoped endpoint-serve executor");
4931
+ if (this.auth || this.remoteAuthority)
4932
+ throw new Error("withOpenServeConnection: an authenticated mesh must use the scoped endpoint-serve executor");
4676
4933
  const nc = await this.dial({ maxReconnectAttempts: 0 });
4677
4934
  try {
4678
4935
  const kvm = new Kvm(nc);
@@ -4867,7 +5124,23 @@ export class Manager {
4867
5124
  // the takeover barrier revokes a deposed manager's ledger cred alongside its goal-writer. The
4868
5125
  // per-session serving creds join the same family, each with its own fresh identity.
4869
5126
  this.sessionLedgerIdentity = newIdentity();
4870
- const run = async ({ recordsKv, authKv, nc: execNc }) => {
5127
+ // One registration operation for this boot. Fresh executors may replace a dead connection, but
5128
+ // they must resume THIS freeze rather than minting a new op that discards Phase-2 progress.
5129
+ const registrationOpId = mintLifecycleUid();
5130
+ const executorExpired = (e) => /closed connection/i.test(e?.message ?? String(e));
5131
+ const retryExpiredExecutor = async (label, fn) => {
5132
+ for (;;) {
5133
+ try {
5134
+ return await fn();
5135
+ }
5136
+ catch (e) {
5137
+ if (!executorExpired(e))
5138
+ throw e;
5139
+ console.error(`manager ${label} executor expired; retrying${label === "registration" ? ` operation ${registrationOpId}` : ""} with fresh scoped authority`);
5140
+ }
5141
+ }
5142
+ };
5143
+ const publishAndProvision = async ({ authKv, nc: execNc }) => {
4871
5144
  // §13.7 contract-artifact publication (1c): every schema root + its closure manifest, plus
4872
5145
  // the cluster document + ITS manifest, land in the EPC store BEFORE the registration that
4873
5146
  // advertises their digests — so a caller can always fetch-verify-compile a registered
@@ -4886,14 +5159,9 @@ export class Manager {
4886
5159
  // so the gate's principal binding is unchanged either way.
4887
5160
  if ((await serveIssuanceGateKv(authKv, this.space, { endpoint: MANAGER_ENDPOINT, instanceId: iid }).observe()) === null)
4888
5161
  await provisionEndpointGateOpen(authKv, { endpoint: MANAGER_ENDPOINT, instanceId: iid, principal: servePrincipal });
4889
- // #783/#871: a predecessor that died mid-barrier leaves this gate frozen under a
4890
- // registration op. registerServiceInstance will then refuse SPEC 13.8 forever, even
4891
- // when the freeze-holder is gone. Complete that SAME op (abort-reopen) on independent
4892
- // holder-gone evidence BEFORE this incarnation freezes a new one. Auth only: the
4893
- // CONNZ oracle rides delivery-admin, which an open mesh does not have.
4894
- if (auth) {
4895
- await this.healFrozenRegistrationGate(authKv, iid, auth, recordsKv);
4896
- }
5162
+ };
5163
+ const completeRegistration = async ({ recordsKv, authKv, nc: execNc }) => {
5164
+ await publishAndProvision({ recordsKv, authKv, nc: execNc });
4897
5165
  // P2 item 3 (slice 3a): on an AUTH mesh a RE-registration (restart of the persisted instanceId)
4898
5166
  // must VERIFY-EVICT the superseded serve family BEFORE the epoch advances (§13.1 "old authority
4899
5167
  // dies before new authority is visible"). Inject the SCOPED delivery-admin evictor; the OPEN
@@ -4902,12 +5170,18 @@ export class Manager {
4902
5170
  // evictor THROWS naming the cure, so PHASE 2 fails closed with the delivery-daemon fix in the
4903
5171
  // error text — a crash-restart never silently skips eviction (no-fallbacks).
4904
5172
  const barrier = endpointRegistrationBarrier(authKv, this.space, {
4905
- endpoint: MANAGER_ENDPOINT, instanceId: iid, opId: mintLifecycleUid(),
5173
+ endpoint: MANAGER_ENDPOINT, instanceId: iid, opId: registrationOpId,
4906
5174
  ...(auth ? { evict: makeManagerEndpointEvictor({ space: this.space, servers: this.servers ?? DEFAULT_SERVER, auth, log: (line) => console.error(line) }) } : {}),
4907
5175
  });
4908
5176
  const spec = { endpoint: MANAGER_ENDPOINT, owner: DEV_OWNER, clusterDigests: [artifacts.closureDigest], protocol: { v: 1 } };
4909
5177
  const { registrationRevision } = await registerServiceInstance(recordsKv, {
4910
5178
  space: this.space, spec, instanceId: iid, registrant: { owner: DEV_OWNER }, authority, barrier, readClusterArtifact,
5179
+ // #1393: when a FOREIGN instance holds the endpoint governance slot, let core tell an
5180
+ // in-flight registration from one abandoned by a predecessor that died between its
5181
+ // slot-take and its spec publish. The read rides THIS executor's existing auth-bucket read
5182
+ // grant (no new grant, no new writer); core refuses on anything but a gate provably
5183
+ // reopened past the slot's stamp.
5184
+ observeHolderGeneration: (holderInstanceId) => readEndpointGateGeneration(authKv, { endpoint: MANAGER_ENDPOINT, instanceId: holderInstanceId }),
4911
5185
  });
4912
5186
  // processEpoch comes from the GATE (checklist 4: never derived from the uid string); the
4913
5187
  // fence below is also the mint's §13.1 release CAS.
@@ -4960,7 +5234,32 @@ export class Manager {
4960
5234
  const sessionLedgerCreds = auth ? await this.mintAndStageSessionLedger(authKv) : undefined;
4961
5235
  return { grant, creds, goalWriterCreds, sessionLedgerCreds };
4962
5236
  };
4963
- const { grant, creds, goalWriterCreds, sessionLedgerCreds } = await (auth ? this.withEndpointServeExecutor(run) : this.withOpenServeConnection(run));
5237
+ let registration;
5238
+ if (auth) {
5239
+ // Boot heal may consume an arbitrarily large prior family. End that executor window here and
5240
+ // mint fresh scoped authority for the new registration, rather than forcing heal + takeover
5241
+ // through one fixed-lifetime connection. A closed connection during heal retries the SAME
5242
+ // frozen predecessor op via reconcile's durable cursor; a closed connection during
5243
+ // registration retries THIS boot's operationId without heal, so Phase-2 progress stays bound.
5244
+ // #783/#871: a predecessor that died mid-barrier leaves this gate frozen under a
5245
+ // registration op. registerServiceInstance will then refuse SPEC 13.8 forever, even
5246
+ // when the freeze-holder is gone. Complete that SAME op (abort-reopen) on independent
5247
+ // holder-gone evidence BEFORE this incarnation freezes a new one. Auth only: the
5248
+ // CONNZ oracle rides delivery-admin, which an open mesh does not have.
5249
+ await retryExpiredExecutor("boot-heal", () => this.withEndpointServeExecutor(async ({ recordsKv, authKv, nc: execNc }) => {
5250
+ await publishAndProvision({ recordsKv, authKv, nc: execNc });
5251
+ if (auth) {
5252
+ await this.healFrozenRegistrationGate(authKv, iid, auth, recordsKv);
5253
+ }
5254
+ }));
5255
+ registration = await retryExpiredExecutor("registration", () => this.withEndpointServeExecutor(completeRegistration));
5256
+ }
5257
+ else {
5258
+ registration = await this.withOpenServeConnection(completeRegistration);
5259
+ }
5260
+ if (!registration)
5261
+ throw new Error("manager registration produced no serve authority");
5262
+ const { grant, creds, goalWriterCreds, sessionLedgerCreds } = registration;
4964
5263
  this.goalWriterCreds = goalWriterCreds;
4965
5264
  this.sessionLedgerCreds = sessionLedgerCreds;
4966
5265
  // The serve connection presents the CURRENT credential on every (re)connect (the state object
@@ -5380,10 +5679,11 @@ export class Manager {
5380
5679
  }
5381
5680
  /** P2 item 2 must-5 Q-B — the boot reconcile: a fresh incarnation (a manager restart takes a NEW
5382
5681
  * instanceId, so the in-memory acceptance map starts empty) inherits the endpoint's accepted-but-
5383
- * unterminal goals from any predecessor. Enumerate the durable index over a scoped PROVISIONER
5384
- * (records CONSUMER.CREATE; the goal-writer holds NO enumeration grant, exactly the ruling) and
5385
- * settle each orphan so an accepted goal is NEVER dropped across a restart. Open mesh: a bare
5386
- * connection (the broker enforces nothing). Runs ONCE at start, BEFORE spawn-as-action begins
5682
+ * unterminal goals from any predecessor. The local signer enumerates with an ephemeral PROVISIONER.
5683
+ * A remote manager calls the authenticated host-owned scan, which keeps the sealed consumer and its
5684
+ * lifecycle authority on the host; the instance executor, goal-writer, and supervisor hold NO records
5685
+ * consumer grant. Settle each orphan so an accepted goal is NEVER dropped across a restart. Open mesh:
5686
+ * a bare connection (the broker enforces nothing). Runs ONCE at start, BEFORE spawn-as-action begins
5387
5687
  * accepting (the `goalReconcileDone` gate), so it never races a live goal's acceptance. Never
5388
5688
  * fatal — a reconcile failure is logged and the gate opens either way. */
5389
5689
  async reconcileGoalIndex() {
@@ -5394,16 +5694,26 @@ export class Manager {
5394
5694
  }
5395
5695
  try {
5396
5696
  let entries = [];
5397
- const nc = this.auth
5398
- ? await this.dial({ ...standaloneConnectOpts({ creds: await mintCreds(this.auth, newIdentity(), "provisioner"), /* not yet wired to a recorded transport */ tls: false }), maxReconnectAttempts: 0 })
5399
- : await this.dial({ maxReconnectAttempts: 0 });
5400
- try {
5401
- const kvm = new Kvm(nc);
5402
- await ensureAuthorityStores(await jetstreamManager(nc), kvm, this.space);
5403
- entries = await listGoalIndex(await kvm.open(recordsBucket(this.space)), MANAGER_ENDPOINT);
5697
+ if (this.remoteAuthority) {
5698
+ entries = (await this.remoteAuthority.scanGoalIndex()).map((entry) => ({
5699
+ ref: { endpoint: entry.endpoint, caller: { owner: entry.owner, actor: entry.actor, uid: entry.uid }, goalId: entry.goalId },
5700
+ iid: entry.iid,
5701
+ ...(entry.allocated !== undefined ? { allocated: entry.allocated } : {}),
5702
+ ...(entry.note !== undefined ? { note: entry.note } : {}),
5703
+ }));
5404
5704
  }
5405
- finally {
5406
- await nc.drain().catch(() => nc.close());
5705
+ else {
5706
+ const nc = this.auth
5707
+ ? await this.dial({ ...standaloneConnectOpts({ creds: await mintCreds(this.auth, newIdentity(), "provisioner"), /* not yet wired to a recorded transport */ tls: false }), maxReconnectAttempts: 0 })
5708
+ : await this.dial({ maxReconnectAttempts: 0 });
5709
+ try {
5710
+ const kvm = new Kvm(nc);
5711
+ await ensureAuthorityStores(await jetstreamManager(nc), kvm, this.space);
5712
+ entries = await listGoalIndex(await kvm.open(recordsBucket(this.space)), MANAGER_ENDPOINT);
5713
+ }
5714
+ finally {
5715
+ await nc.drain().catch(() => nc.close());
5716
+ }
5407
5717
  }
5408
5718
  // Single-manager item 2: EVERY inherited entry belongs to a DEAD predecessor (only one manager
5409
5719
  // at a time), so all are reconciled. The `iid` field is the hook item-3's multi-instance sweep
@@ -6241,6 +6551,97 @@ export class Manager {
6241
6551
  // the goal already terminalized (the readiness outcome won the settle race) - nothing to cancel.
6242
6552
  }
6243
6553
  }
6554
+ /** Re-record the custody reference of a managed agent's CURRENT handle on its active slot: a
6555
+ * same-lifecycle restart or a resume binds a new custody under the old uid, and the successor's
6556
+ * reap must address the live one. Static auth only; a runtime without durable custody records
6557
+ * nothing (there is nothing to reap by reference). Loud on failure, never fatal to the bind: the
6558
+ * handle is already live and a stale reference is refused by identity at reap time. */
6559
+ async recordSlotRuntime(a) {
6560
+ if (a.handle.reference === undefined)
6561
+ return;
6562
+ await this.recordSlotCustody(a, a.handle.reference);
6563
+ }
6564
+ /** Record one custody reference on an agent's ACTIVE slot. Split from {@link recordSlotRuntime}
6565
+ * so a restart or a resume can record the reference it RESERVED before spawning, while no
6566
+ * handle exists yet. Static auth only; a runtime without durable custody reserves nothing. */
6567
+ async recordSlotCustody(a, runtime) {
6568
+ if (!this.auth || this.userMode)
6569
+ return;
6570
+ try {
6571
+ await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: a.id, lifecycleUid: a.lifecycleUid, alias: a.name }, async (t) => {
6572
+ const slot = await readStaticSlot(t, DEV_OWNER, a.name);
6573
+ if (slot === undefined || slot.row.lifecycleUid !== a.lifecycleUid || slot.row.phase !== "active")
6574
+ return;
6575
+ if (slot.row.runtime?.kind === runtime.kind && slot.row.runtime.id === runtime.id)
6576
+ return;
6577
+ await casStaticSlot(t, { ...slot.row, runtime }, slot.revision);
6578
+ });
6579
+ }
6580
+ catch (e) {
6581
+ console.error(`! ${a.name}: could not record the custody reference ${runtime.kind}:${runtime.id} on its static slot: ${e.message} - a successor reaps this seat only by an up-to-date reference`);
6582
+ }
6583
+ }
6584
+ /** Mint the custody reference for a seat this manager is about to spawn, BEFORE the processes
6585
+ * exist. Absent for a runtime with no durable custody (there is nothing a successor could reap
6586
+ * by reference), which is the only case that still spawns unreserved. */
6587
+ reserveCustody() {
6588
+ return isCustodialRuntime(this.runtime) ? this.runtime.reserve() : undefined;
6589
+ }
6590
+ /** Spawn under a reserved reference and prove the runtime honoured it. A runtime that minted its
6591
+ * own id instead would leave every durable record addressing a seat that does not exist, so the
6592
+ * mismatch tears the new seat down and throws rather than returning an unaddressable handle. */
6593
+ async spawnCustodied(name, spec, cwd, reserved) {
6594
+ const handle = this.runtime.spawn(name, spec, cwd, reserved);
6595
+ if (reserved === undefined)
6596
+ return handle;
6597
+ const got = handle.reference;
6598
+ if (got?.kind === reserved.kind && got.id === reserved.id)
6599
+ return handle;
6600
+ // A live seat now exists under a reference no durable row names, and the rollback will reap the
6601
+ // RESERVED one, which addresses nothing. `handle.stop` cannot close this: it is fire-and-forget
6602
+ // over the seat socket and swallows its own errors, so it proves nothing about the process. Reap
6603
+ // the reference the runtime ACTUALLY used — that path verifies identity and proves the exit —
6604
+ // and carry what the proof said into the throw, so a mismatch never reports a disposal it did
6605
+ // not make.
6606
+ let disposal;
6607
+ if (got === undefined) {
6608
+ try {
6609
+ handle.stop({ graceful: false });
6610
+ }
6611
+ catch { /* reported below */ }
6612
+ disposal = "the handle carries no reference, so nothing could reap it by identity and only a best-effort stop was issued";
6613
+ }
6614
+ else {
6615
+ try {
6616
+ const evidence = await requireRuntimeReap(this.runtime, got);
6617
+ disposal = `the spawned seat was reaped (${evidence.outcome === "absent" ? "already forgotten by the runtime" : evidence.detail})`;
6618
+ }
6619
+ catch (e) {
6620
+ disposal = `the spawned seat could NOT be reaped: ${e.message}`;
6621
+ }
6622
+ }
6623
+ throw new Error(`runtime "${this.runtime.kind}" reserved custody ${reserved.kind}:${reserved.id} for "${name}" but spawned ` +
6624
+ `${got ? `${got.kind}:${got.id}` : "a handle with no reference"}; the recorded reference would address no seat, so ${disposal}`);
6625
+ }
6626
+ /** The terminal's process step: an orphan (no live handle in THIS process) is reaped by the custody
6627
+ * reference the slot recorded, before its footprint goes. A successor must never retire a
6628
+ * lifecycle and free its alias while the predecessor's seat process is still running outside
6629
+ * every manager. The runtime verifies the process identity against its own record and proves the
6630
+ * exit; a runtime that cannot reap refuses by name and the lifecycle stays terminalizing. A row
6631
+ * with no recorded reference has nothing this manager can address. */
6632
+ async reapOrphanSeat(a) {
6633
+ if (a.runtime === undefined) {
6634
+ // No reference reached this terminal. Under a custodying runtime that is either a row written
6635
+ // before the reservation existed, or a caller that dropped the reference on the way here.
6636
+ // Do not name the slot row: the row may well hold a reference this caller never read, and
6637
+ // a diagnostic that guesses which is which sends the reader the wrong way.
6638
+ if (isCustodialRuntime(this.runtime))
6639
+ console.error(`static retirement ${a.name}: no custody reference reached this terminal, though runtime "${this.runtime.kind}" custodies its seats; any seat it launched is not addressable from here`);
6640
+ return;
6641
+ }
6642
+ const evidence = await requireRuntimeReap(this.runtime, a.runtime);
6643
+ console.error(`static retirement ${a.name}: orphan seat process ${evidence.outcome === "absent" ? `already forgotten by runtime "${a.runtime.kind}" (${a.runtime.id})` : evidence.detail}`);
6644
+ }
6244
6645
  /** The static F1 terminal for one departed incarnation (Unit B): delegates the gate/head CAS
6245
6646
  * sequence to the shared core saga over the executor transport; the footprint teardown (creds
6246
6647
  * file + broker durables/ACL) runs INSIDE the barrier as its cleanup step. On completion the
@@ -6263,6 +6664,13 @@ export class Manager {
6263
6664
  log: (line) => console.error(`static retirement ${a.name}: ${line}`),
6264
6665
  });
6265
6666
  const cleanup = async () => {
6667
+ // SPEC 13.15: every issuance bound to this incarnation's ledger family is retired here,
6668
+ // inside the barrier and after the ledger rows were revoked (the source is frozen first,
6669
+ // then the index is walked). A retirement that lost is surfaced, never skipped: an active
6670
+ // attempt row on a retired lifecycle would authorize a run the credential no longer can.
6671
+ const retired = await this.withIssuer((i) => i.store.retireSource({ space: this.space, bucket: epAuthBucket(this.space), key: `cred.${a.lifecycleUid}` }));
6672
+ if (retired > 0)
6673
+ console.error(`static retirement ${a.name}: retired ${retired} issuance(s) bound to uid ${a.lifecycleUid}`);
6266
6674
  const secrets = this.secrets;
6267
6675
  const files = a.secretPaths ?? agentLifecycleSecretFilePaths(this.workspaceRoot, this.space, a.name, a.lifecycleUid);
6268
6676
  if (files.creds) {
@@ -6271,19 +6679,24 @@ export class Manager {
6271
6679
  }
6272
6680
  await this.deprovisionBroker(a);
6273
6681
  };
6682
+ // The process goes before its footprint: reap the orphan seat by reference, then tear down.
6683
+ const reapThenCleanup = async () => {
6684
+ await this.reapOrphanSeat(a);
6685
+ await cleanup();
6686
+ };
6274
6687
  try {
6275
6688
  await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: a.id, lifecycleUid: a.lifecycleUid, alias: a.name }, async (t) => {
6276
6689
  const slot = await readStaticSlot(t, DEV_OWNER, a.name);
6277
6690
  if (slot === undefined || slot.row.lifecycleUid !== a.lifecycleUid) {
6278
6691
  // No durable registration for THIS incarnation: a pre-Unit-B spawn (or a slot already
6279
6692
  // replaced by a successor — then this stale teardown must not touch the registry at all).
6280
- await cleanup();
6693
+ await reapThenCleanup();
6281
6694
  return;
6282
6695
  }
6283
6696
  await runStaticTerminal(t, {
6284
6697
  owner: DEV_OWNER, alias: a.name, actor: a.id, lifecycleUid: a.lifecycleUid, opId,
6285
6698
  managerInstance: this.managerInstanceId, managerProcessUid: this.managerLifecycleUid,
6286
- }, { cleanup, evict, log: (line) => console.error(`static retirement ${a.name}: ${line}`) });
6699
+ }, { cleanup: reapThenCleanup, evict, log: (line) => console.error(`static retirement ${a.name}: ${line}`) });
6287
6700
  });
6288
6701
  this.retiredPrincipals.add(principalKey(DEV_OWNER, a.id).key);
6289
6702
  const cur = this.retiring.get(a.name);
@@ -6327,19 +6740,22 @@ export class Manager {
6327
6740
  // The SAME permission scope the spawn minted (recorded on the managed row): allowSubscribe/
6328
6741
  // allowPublish/role/capabilities are the JWT-shaping inputs; `subscribe` (the active read
6329
6742
  // set) shapes durable membership only and is not a mint input.
6330
- const creds = await mintCreds(this.auth, { id: a.id, seed: a.seed }, "agent", {
6743
+ if (!a.issued)
6744
+ throw new Error(`renewManagedStaticCred: ${a.name} carries no issuance; a static credential minted before SPEC 13.15 is not renewed under an unbound generation - respawn the agent`);
6745
+ // A RENEWAL keeps the generation (SPEC 13.15): the same JWT-shaping inputs produce the same
6746
+ // ceiling, which `confirm` verifies against the recorded evidence before the ledger append. A
6747
+ // changed ceiling is refused there: that is a fresh issuance on a new connection, never a renewal.
6748
+ const issued = a.issued;
6749
+ const creds = await this.withIssuer((i) => mintCreds(this.auth, { id: a.id, seed: a.seed }, "agent", {
6331
6750
  allowSubscribe: a.launch.allowSubscribe,
6332
6751
  allowPublish: a.launch.allowPublish,
6333
6752
  role: a.role,
6334
6753
  capabilities: a.launch.capabilities,
6335
6754
  lifecycleUid: a.lifecycleUid,
6336
6755
  expiresAt: exp,
6337
- });
6338
- const credentialId = rawDigest(creds).replace("sha256:", "sha256-");
6339
- await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: a.id, lifecycleUid: a.lifecycleUid, alias: a.name }, async (t) => {
6340
- await recordSlotCredential(t, DEV_OWNER, a.name, a.lifecycleUid, credentialId);
6341
- await appendStaticCredentialRow(t, { lifecycleUid: a.lifecycleUid, credentialId, holderPrincipal: principalKey(DEV_OWNER, a.id).key, exp });
6342
- });
6756
+ issued,
6757
+ issuance: this.issuanceSeam(i, "renew", a, exp),
6758
+ }));
6343
6759
  const secrets = this.secrets;
6344
6760
  const credsPath = a.secretPaths.creds;
6345
6761
  await secrets.put(agentSecretKeyForFile(credsPath, this.space), creds);
@@ -6505,7 +6921,7 @@ export class Manager {
6505
6921
  this.reconcilingAliases.add(row.alias);
6506
6922
  console.error(`static reconcile terminal alias=${row.alias} phase=${row.phase} uid=${row.lifecycleUid} attempt=${item.attempts}/${item.maxAttempts}`);
6507
6923
  try {
6508
- await this.driveStaticRetirement({ id: row.actor, name: row.alias, lifecycleUid: row.lifecycleUid }, true);
6924
+ await this.driveStaticRetirement({ id: row.actor, name: row.alias, lifecycleUid: row.lifecycleUid, runtime: row.runtime }, true);
6509
6925
  if (item.attempts === 1) {
6510
6926
  this.staticReconcileItems.delete(key);
6511
6927
  }
@@ -6799,12 +7215,14 @@ export class Manager {
6799
7215
  /** Persist a peer-defined persona as config. After this, `start name` auto-discovers
6800
7216
  * .cotal/agents/<name>.md and the connector applies its persona/model at spawn.
6801
7217
  *
6802
- * CONTENT vs POLICY (P6): the write path accepts ONLY content from args {name, model,
6803
- * persona}. role/publish/capabilities/owner are POLICY and have no slot here, so a peer can
6804
- * never grant itself a capability or claim ownership by redefining. A fresh name is created with
6805
- * owner = caller (the creator). Redefining an EXISTING file overwrites ONLY model + persona and
6806
- * preserves everything else and is allowed on the privileged tier only if `file.owner == caller`,
6807
- * else admin is required. Fail-closed: an ownerless file (legacy / operator-written) is admin-only. */
7218
+ * CONTENT vs POLICY (P6): `capabilities` and `owner` still have no slot, so a peer cannot
7219
+ * self-grant spawn or claim ownership. Channel grants, role, and agent are content: they may
7220
+ * arrive as explicit arguments or as a leading frontmatter block inside `persona`. A prompt
7221
+ * that starts with `---` is parsed and merged (explicit args win) so the written file has one
7222
+ * frontmatter block; a malformed block is refused by name rather than wrapped. A fresh name is
7223
+ * created with owner = caller. Redefining an EXISTING file is allowed on the privileged tier
7224
+ * only if `file.owner == caller`, else admin is required. Fail-closed: an ownerless file
7225
+ * (legacy / operator-written) is admin-only. */
6808
7226
  opDefinePersona(args, caller, admin) {
6809
7227
  const name = String(args.name ?? "").trim();
6810
7228
  if (!name)
@@ -6815,55 +7233,42 @@ export class Manager {
6815
7233
  const persona = String(args.persona ?? "").trim();
6816
7234
  if (!persona)
6817
7235
  return { ok: false, error: "persona required" };
6818
- const model = args.model ? String(args.model) : undefined;
7236
+ const model = args.model !== undefined && args.model !== null && String(args.model) !== "" ? String(args.model) : undefined;
6819
7237
  const path = agentFilePath(this.workspaceRoot, name);
6820
- let def;
7238
+ let existing;
6821
7239
  if (existsSync(path)) {
6822
- // Redefine: load, authorize by ownership, then overwrite ONLY content; preserve all policy.
6823
7240
  try {
6824
- def = loadAgentFile(path);
7241
+ existing = loadAgentFile(path);
6825
7242
  }
6826
7243
  catch (e) {
6827
7244
  return { ok: false, error: e.message };
6828
7245
  }
6829
- if (!admin && def.owner !== caller) {
6830
- const owner = def.owner ? `owned by ${def.owner}` : "operator-owned (legacy file - no agent owner)";
7246
+ if (!admin && existing.owner !== caller) {
7247
+ const owner = existing.owner ? `owned by ${existing.owner}` : "operator-owned (legacy file - no agent owner)";
6831
7248
  return { ok: false, error: `not authorized to redefine ${name}: ${owner}; only its owner or an operator can` };
6832
7249
  }
6833
- // PATCH content: overwrite model only when provided, so a persona-only redefine can't wipe an existing model.
6834
- if (model !== undefined)
6835
- def.model = model;
6836
- def.persona = persona;
6837
- // A redefine cannot change scope, so a file that still has no channels keeps its marker. But
6838
- // if an operator has since given it a real read set, the marker is a stale claim about a state
6839
- // that no longer holds, and a marker that outlives its condition is worse than none: it tells
6840
- // a census the scope was never chosen when someone chose it.
6841
- //
6842
- // The condition is the READ SET specifically, not any channel field. `allowSubscribe` alone is
6843
- // a ceiling on what the agent may read, not the set it reads, so a persona given only that
6844
- // still reads nothing and the marker still describes it correctly.
6845
- if (def.meta?.scope_source === "wire-default" && def.subscribe?.length) {
6846
- const { scope_source: _dropped, ...rest } = def.meta;
6847
- def.meta = Object.keys(rest).length ? rest : undefined;
6848
- }
6849
7250
  }
6850
- else {
6851
- // Fresh name: create with content + owner = caller. The privileged tier suffices (creating a
6852
- // brand-new persona isn't admin-only); the creator becomes its owner.
6853
- //
6854
- // The read set is EMPTY, and that is a policy decision made here rather than a field left
6855
- // blank. A peer cannot name its own channels through this path by design (see CONTENT vs
6856
- // POLICY above): letting it would make defining a persona a way to grant reads. So the only
6857
- // safe scope for a peer-created persona is none, and an operator widens it afterwards. It is
6858
- // written explicitly so the file states it, instead of being an omission a later default
6859
- // could reinterpret.
6860
- //
6861
- // `scope_source` records WHY it is empty. Everywhere else an empty read set means the author
6862
- // chose none; here the author was never offered the choice, and the two are indistinguishable
6863
- // in the file without this. A reader counting deliberate empties would otherwise credit this
6864
- // path with an intent nobody expressed. An operator setting a real read set should drop the
6865
- // marker, and `cotal personas edit` re-validates on save.
6866
- def = { name, model, persona, owner: caller, subscribe: [], meta: { scope_source: "wire-default" } };
7251
+ const strList = (v) => {
7252
+ if (v === undefined || v === null)
7253
+ return undefined;
7254
+ return (Array.isArray(v) ? v : [v]).map((x) => String(x));
7255
+ };
7256
+ let def;
7257
+ try {
7258
+ def = composeWirePersona({
7259
+ name,
7260
+ owner: caller,
7261
+ prompt: persona,
7262
+ model,
7263
+ role: args.role !== undefined && args.role !== null && String(args.role) !== "" ? String(args.role) : undefined,
7264
+ agent: args.agent !== undefined && args.agent !== null && String(args.agent) !== "" ? String(args.agent) : undefined,
7265
+ subscribe: strList(args.subscribe),
7266
+ allowSubscribe: strList(args.allowSubscribe),
7267
+ allowPublish: strList(args.allowPublish),
7268
+ }, existing);
7269
+ }
7270
+ catch (e) {
7271
+ return { ok: false, error: e.message };
6867
7272
  }
6868
7273
  try {
6869
7274
  saveAgentFile(path, def);
@@ -6968,7 +7373,7 @@ export class Manager {
6968
7373
  *
6969
7374
  * `enter` defaults to true: a harness command typed but not submitted has not been delivered.
6970
7375
  * Nothing is echoed back; the caller reads the resulting turns from the event plane. */
6971
- inputAuthorized(a, args) {
7376
+ async inputAuthorized(a, args) {
6972
7377
  if (this.agents.get(a.name) !== a)
6973
7378
  throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" was replaced during authorization - retry`);
6974
7379
  if (a.handle.status() !== "running")
@@ -6980,8 +7385,17 @@ export class Manager {
6980
7385
  // the only decision left is the carriage return. `!== false` and not `?? true`: an ABSENT enter
6981
7386
  // and an explicit `true` must behave identically, and only `false` may suppress the return.
6982
7387
  const data = `${String(args.text)}${args.enter !== false ? "\r" : ""}`;
6983
- write(data);
6984
- return { name: a.name, bytes: Buffer.byteLength(data, "utf8") };
7388
+ const intendedBytes = Buffer.byteLength(data, "utf8");
7389
+ let bytes;
7390
+ try {
7391
+ bytes = await write(data);
7392
+ }
7393
+ catch (error) {
7394
+ throw new EpEnvelopeError("unavailable", `input for seat "${a.name}" failed: ${error.message}`);
7395
+ }
7396
+ if (!Number.isSafeInteger(bytes) || bytes !== intendedBytes)
7397
+ throw new EpEnvelopeError("unavailable", `input for seat "${a.name}" failed: runtime accepted ${String(bytes)} of ${intendedBytes} bytes`);
7398
+ return { name: a.name, bytes };
6985
7399
  }
6986
7400
  /** The post-authorization attach effect (P2 item 6): mint the holder-bound §13.6 offer, redeem it
6987
7401
  * through the ONE session plane (one-use CAS + presenter-equality), and stand up the PTY bridge —
@@ -7075,6 +7489,14 @@ export class Manager {
7075
7489
  * owner-domain bound); undefined = unbounded. {@link NO_OWNER_MATCHES} matches nothing. */
7076
7490
  list(ownerFilter) {
7077
7491
  const roster = new Map(this.ep.getRoster().map((p) => [p.card.name, p]));
7492
+ // The roster is only evidence while THIS observer's presence watch is fresh. A stale view
7493
+ // (whole-bucket silence past TTL) or an unpopulated one (snapshot not yet replayed) cannot
7494
+ // support "offline" or "absent" for anyone: netcup 2026-09-09 rendered every live seat as
7495
+ // one of those for hours after the presence stream was recreated under a still-open watch.
7496
+ // Carry the view state on the row so the renderer can say "unknown" instead of a verdict.
7497
+ // An endpoint that reports no view (test doubles built on `getRoster` alone) is read as
7498
+ // `current`: that is exactly what every row meant before the field existed.
7499
+ const view = typeof this.ep.presenceView === "function" ? this.ep.presenceView() : { state: "current" };
7078
7500
  return [...this.agents.values()].filter((a) => ownerFilter === undefined || a.userOwner === ownerFilter).map((a) => {
7079
7501
  // USER MODE: a detached agent's bearer-refresh death is silent everywhere except here — its
7080
7502
  // bearer command writes each attempt's outcome to the health file, and `ps` renders it
@@ -7096,6 +7518,9 @@ export class Manager {
7096
7518
  status: a.handle.status(),
7097
7519
  uptimeMs: Date.now() - a.startedAt,
7098
7520
  mesh: roster.get(a.name)?.status ?? "absent",
7521
+ // `current` is the only state in which `mesh` is a verdict; the other two are the
7522
+ // observer's own condition and travel on the row (older CLIs ignore the field).
7523
+ meshView: view.state,
7099
7524
  // The incarnation coordinate (SPEC 13.1) — with `id`, exactly what a v0.4 caller needs to
7100
7525
  // build a targeted (`despawn`/`attach`) request against THIS incarnation.
7101
7526
  lifecycleUid: a.lifecycleUid,