@cotal-ai/manager 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/attach-endpoint.d.ts +53 -59
  2. package/dist/attach-endpoint.d.ts.map +1 -1
  3. package/dist/attach-endpoint.js +120 -244
  4. package/dist/attach-endpoint.js.map +1 -1
  5. package/dist/commands.js +9 -4
  6. package/dist/commands.js.map +1 -1
  7. package/dist/console/app.js +68 -30
  8. package/dist/console/index.html +3 -2
  9. package/dist/console/session-bundle.js +9736 -0
  10. package/dist/console-crypto-shim.d.ts +16 -0
  11. package/dist/console-crypto-shim.d.ts.map +1 -0
  12. package/dist/console-crypto-shim.js +20 -0
  13. package/dist/console-crypto-shim.js.map +1 -0
  14. package/dist/console-session-entry.d.ts +2 -0
  15. package/dist/console-session-entry.d.ts.map +1 -0
  16. package/dist/console-session-entry.js +26 -0
  17. package/dist/console-session-entry.js.map +1 -0
  18. package/dist/endpoint-evict.d.ts +29 -0
  19. package/dist/endpoint-evict.d.ts.map +1 -0
  20. package/dist/endpoint-evict.js +80 -0
  21. package/dist/endpoint-evict.js.map +1 -0
  22. package/dist/manager-service-contract.d.ts +132 -0
  23. package/dist/manager-service-contract.d.ts.map +1 -0
  24. package/dist/manager-service-contract.js +332 -0
  25. package/dist/manager-service-contract.js.map +1 -0
  26. package/dist/manager.d.ts +504 -22
  27. package/dist/manager.d.ts.map +1 -1
  28. package/dist/manager.js +2193 -257
  29. package/dist/manager.js.map +1 -1
  30. package/dist/runtime/pty.d.ts.map +1 -1
  31. package/dist/runtime/pty.js +15 -2
  32. package/dist/runtime/pty.js.map +1 -1
  33. package/dist/session/bridge.d.ts +63 -0
  34. package/dist/session/bridge.d.ts.map +1 -0
  35. package/dist/session/bridge.js +157 -0
  36. package/dist/session/bridge.js.map +1 -0
  37. package/dist/session/establish.d.ts +212 -0
  38. package/dist/session/establish.d.ts.map +1 -0
  39. package/dist/session/establish.js +174 -0
  40. package/dist/session/establish.js.map +1 -0
  41. package/dist/session/index.d.ts +12 -0
  42. package/dist/session/index.d.ts.map +1 -0
  43. package/dist/session/index.js +15 -0
  44. package/dist/session/index.js.map +1 -0
  45. package/dist/session/plane.d.ts +120 -0
  46. package/dist/session/plane.d.ts.map +1 -0
  47. package/dist/session/plane.js +327 -0
  48. package/dist/session/plane.js.map +1 -0
  49. package/dist/static-lifecycle.d.ts +97 -0
  50. package/dist/static-lifecycle.d.ts.map +1 -0
  51. package/dist/static-lifecycle.js +262 -0
  52. package/dist/static-lifecycle.js.map +1 -0
  53. package/package.json +9 -5
package/dist/manager.js CHANGED
@@ -3,14 +3,30 @@ import { createHash, randomUUID, randomBytes } from "node:crypto";
3
3
  import { connect, credsAuthenticator } from "@nats-io/transport-node";
4
4
  import { existsSync, lstatSync, readFileSync, rmSync } from "node:fs";
5
5
  import { join, dirname, resolve } from "node:path";
6
- import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_TTL_MS, STANDING_RENEWABLE_TTL_SEC, agentFilePath, clearSpaceHistory, connectorServers, deprovisionAgent, firstFreeName, idFromCreds, loadAgentFile, loadCotalConfig, mintCreds, mintLifecycleUid, mkSecretDir, newIdentity, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, CONTROL_PRIVILEGED, CONTROL_SELF_SERVICE, CONTROL_ADMIN, CONTROL_AUTH_ADMIN, controlServiceSubject, } from "@cotal-ai/core";
7
- import { agentActorTokenKey, agentAuthState, agentCredsDir, agentCredsKey, agentSecretFilePaths, agentSentinelCredsKey, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KEY, findCotalRoot, getSpaceAuth, hasUserAuthState, loadMeshes, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KEY, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
6
+ import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_TTL_MS, STANDING_RENEWABLE_TTL_SEC, agentFilePath, clearSpaceHistory, connectorServers, deprovisionAgent, firstFreeName, idFromCreds, inspectCredHealth, loadAgentFile, loadCotalConfig, mintCreds, mintLifecycleUid, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, CONTROL_AUTH_ADMIN, controlServiceSubject, } from "@cotal-ai/core";
7
+ import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KEY, findCotalRoot, getSpaceAuth, hasUserAuthState, loadManagerInstanceIdentity, loadMeshes, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KEY, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, saveManagerInstanceIdentity, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
8
8
  import { createRuntime, } from "./runtime/index.js";
9
- import { AttachEndpoint, attachHost } from "./attach-endpoint.js";
9
+ import { AttachEndpoint } from "./attach-endpoint.js";
10
+ import { makeManagerEndpointEvictor } from "./endpoint-evict.js";
10
11
  import { launchSpecForRun, materializePersona, launchAgentToStartOpts, parseLaunchSpec, persistLaunchSpec } from "./launch.js";
11
12
  import { authorizeLaunch, authorizeNamedControl } from "./authorize.js";
12
13
  import { controlShutdown } from "./control-shutdown.js";
13
14
  import { parseResumeCommitArgs, parseResumeControlArgs, parseResumeFinalizeArgs } from "./resume.js";
15
+ // Unit B (the static §13.1 lifecycle executor): the shared grammar/stores from core plus the
16
+ // manager-side adapter (transport + slot orchestration + the F1 terminal) — see static-lifecycle.ts.
17
+ import { jetstreamManager } from "@nats-io/jetstream";
18
+ import { Kvm } from "@nats-io/kv";
19
+ import { recordsBucket, epAuthBucket, ensureAuthorityStores, ensureContractStore, createEndpointStreams, contractStoreContext, publishContractArtifact, contractArtifactCanonicalBytes, standaloneConnectOpts, STATIC_SLOT_PREFIX, rawDigest, STANDING_RENEWABLE_TTL_SEC as MANAGED_STATIC_TTL_SEC, newArtifactSigner, sessionsBucket, SESSION_GRANT_MAX_TTL_MS, } from "@cotal-ai/core";
20
+ // P2 item 6: the manager's ONE §13.6 session plane — offer mint + one-use redeem + PTY-bridge
21
+ // standup for `attach`, over a dedicated standing session-LEDGER connection (the byte rails ride
22
+ // per-session credentials on their own short-lived connections).
23
+ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
24
+ // P2 item 1 (1a-serve): the manager as an ordinary v0.4 `service` endpoint — the §13.1
25
+ // endpoint-serve credential subsystem (gate provisioning, registration barrier, mint fence) plus
26
+ // the register/authorize/serve seams, all driven over a scoped one-shot executor connection.
27
+ import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, registerServiceInstance, authorizeServeGrant, writeServiceStatus, SERVICE_READY, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, settleGoalUncertain, readGoalResult, readGoalStatus, readGoalSpec, recordGoalIndex, readGoalIndex, clearGoalIndex, listGoalIndex, GOAL_TERMINAL_STATES, goalRefOf, goalProgressTopic, epeSubject, submissionFingerprint, EpEnvelopeError, } from "@cotal-ai/core";
28
+ import { MANAGER_ENDPOINT, managerClusterArtifacts, managerCommandDefs, managerContractArtifactValues } from "./manager-service-contract.js";
29
+ import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, } from "./static-lifecycle.js";
14
30
  /** Concurrency ceiling — the manager refuses to hold more than this many live + in-flight +
15
31
  * cooling slots at once (P4a). Bounds a fork-bomb: spawn is a full agent process per call. */
16
32
  const MAX_AGENTS = 50;
@@ -75,17 +91,16 @@ function withTimeout(p, ms, msg) {
75
91
  function sameStrings(a, b) {
76
92
  return JSON.stringify([...(a ?? [])].sort()) === JSON.stringify([...(b ?? [])].sort());
77
93
  }
78
- /**
79
- * The agent supervisor: a long-lived mesh node that owns agent process lifecycle.
80
- * It serves control requests on the "manager" service and spawns/kills agents
81
- * through a pluggable {@link Runtime} (pty by default). It does NOT proxy agent
82
- * mesh traffic — terminal I/O streams over its own attach endpoint instead.
83
- */
84
94
  export class Manager {
85
95
  space;
86
96
  servers;
97
+ /** P2 item 6: the broker ws listener port (loopback) `cotal up` allocated, for the console session
98
+ * client's wsUrl. Undefined ⇒ no console session client (POST /session 503s). */
99
+ wsPort;
87
100
  name;
88
101
  workspaceRoot;
102
+ /** P2 item 6: the operator-set global live-session ceiling (see {@link ManagerOptions.maxSessions}). */
103
+ maxSessions;
89
104
  /** The ONE secret store for every kind this manager touches (daemon-cred remint + agent kinds).
90
105
  * See {@link ManagerOptions.secretStore}. */
91
106
  secrets;
@@ -115,6 +130,82 @@ export class Manager {
115
130
  * same-name SUCCESSOR (which can spawn after the hold clears but before this flight's `nc.close`
116
131
  * yield settles) never joins the predecessor's rail request and skips its own retirement. */
117
132
  retiringFlight = new Map();
133
+ /** Wire principals of RETIRED static incarnations (Unit B, F5(a)): populated at every completed
134
+ * static terminal and from the boot sweep's retired slot rows, so a copied credential of a
135
+ * retired incarnation is refused at the control surface even across a manager restart. The
136
+ * durable truth is the slot row + principal-keyed head; this set is the in-memory index of it
137
+ * (one string per retired incarnation — bounded by lifecycle count, never pruned in-process). */
138
+ retiredPrincipals = new Set();
139
+ /** This manager process's own incarnation uid (SPEC 13.1; minted once per supervisor process,
140
+ * never reused across restarts) — the endpoint's presence key AND the `managerInstance` audit
141
+ * coordinate every static activation records. */
142
+ managerLifecycleUid = mintLifecycleUid();
143
+ /** The persisted LOGICAL instance id (SPEC 13.6 item 7, P2 item 3): STABLE across restart, so a
144
+ * restart re-registers the SAME id with an ADVANCED epoch through the §13.1 gate (the successor
145
+ * fences the predecessor's epoch — the (i) fence bites on a real restart). It is the registration
146
+ * instanceId, the served-status id, the goal spec's executor.instanceId, the epe route, and the
147
+ * resolveExecutorEpoch key — DISTINCT from {@link managerLifecycleUid} (per-process: the presence
148
+ * node uid + the managerInstance audit coordinate every static activation records). Set in start(). */
149
+ managerInstanceId;
150
+ /** The persisted serve nkey identity: reusing the SAME principal across restart keeps
151
+ * {@link provisionEndpointGateOpen} idempotent (no core barrier change) and gives verified
152
+ * eviction a stable target (the predecessor's connections under this principal). Set in start(). */
153
+ managerServeIdentity;
154
+ /** P2 item 1 (1a-serve): the manager's v0.4 service-endpoint serve state — the serve handle +
155
+ * its dedicated connection, the STABLE serve identity (renewals re-mint the same nkey), the
156
+ * branded serve grant, and the CURRENT credential (the connection's authenticator reads it on
157
+ * every (re)connect, so a renewal is adopted without re-registration). Absent on open meshes,
158
+ * in user mode (the named 1a follow-up), and before registration completes. */
159
+ serviceServe;
160
+ /** P2 item 2 (spawn-as-action): the SELF-MEDIATED goal-writer connection + ActionContext — a
161
+ * standing connection DISJOINT from the serve credential (Q2), scoped to exactly this endpoint's
162
+ * goal bind/terminal facts + goal-record writes ({@link goalWriterGrants}). Auth mode mints the
163
+ * `goal-writer` cred; an open mesh uses a bare connection (no credential system to mint from).
164
+ * `gate` (auth mode) is the own-issuance-gate READER for the must-5 (a) currency belt — the
165
+ * manager reads its OWN `epgate.<e>.<iid>` epoch over this connection before a terminal commit
166
+ * and skips a superseded commit (the fast-fail belt paired with the (b) barrier-revoke fence). */
167
+ goalWriter;
168
+ /** P2 item 2 must-5 (b): the STABLE goal-writer identity (auth mode) — minted once at
169
+ * registration alongside the serve identity; a renewal re-mints the SAME nkey with a fresh
170
+ * bounded exp and re-stages its distinct credId into the §13.1 revocation family. The current
171
+ * goal-writer credential is minted INSIDE {@link registerManagerService}'s run block (fence
172
+ * live) and stashed here for {@link startGoalWriter} to build the standing connection from. */
173
+ goalWriterIdentity;
174
+ goalWriterCreds;
175
+ /** P2 item 6: the manager's ONE §13.6 session plane — offer mint + one-use redeem + PTY-bridge
176
+ * standup for `attach`. The face's establisher and the CLI attach handler both call THIS one
177
+ * plane; the manager never constructs a second. Undefined until {@link startSessionPlane}. */
178
+ sessionPlane;
179
+ /** P2 item 6: the standing session-LEDGER connection + its mutable creds holder (the authenticator
180
+ * presents the refreshed cred on the next reconnect after a half-TTL renewal — the goal-writer
181
+ * precedent). Auth mode only; an open mesh runs the plane over a bare connection. */
182
+ sessionLedgerConn;
183
+ /** P2 item 6: credentialId → the nkey that credential was minted for, for the live per-session
184
+ * SERVING credentials. The §13.1 ledger row records the holder principal, and the row is written
185
+ * at stage time (after the mint), so the two steps need this one hop. Entries are dropped at
186
+ * revoke; a session that never staged drops its entry when the manager exits. */
187
+ sessionServingKeys = new Map();
188
+ /** P2 item 6: the STABLE session-LEDGER identity (auth mode) — minted once at registration
189
+ * alongside the serve + goal-writer identities; a renewal re-mints the SAME nkey with a fresh
190
+ * bounded exp and re-stages its distinct credId into the §13.1 revocation family. The current
191
+ * credential is minted INSIDE {@link registerManagerService}'s run block and stashed here. */
192
+ sessionLedgerIdentity;
193
+ sessionLedgerCreds;
194
+ /** P2 item 2: the acceptance replied for each in-flight goalId this incarnation accepted, so an
195
+ * idempotent same-goalId retry serves the IDENTICAL acceptance (same allocated name/triple) without
196
+ * a second spawn. Durable cross-incarnation reconstruction rides the must-5 goal-index; here the
197
+ * live map covers same-incarnation retries, with the committed result fact as the fallback. */
198
+ goalAcceptances = new Map();
199
+ /** P2 item 2 must-5 Q-B: the boot reconcile of the durable goal index runs ONCE at start (a
200
+ * fresh incarnation inherits the endpoint's accepted-but-unterminal goals from any predecessor).
201
+ * Spawn-as-action REFUSES to accept until it completes, so the sweep never races a live goal's
202
+ * acceptance (settling one mid-flight would steal its real terminal). */
203
+ goalReconcileDone = false;
204
+ /** P2 item 2 (M4): the live spawn goal ref for each managed agent name, so a despawn MID-GOAL
205
+ * drives the cancel path (transition -> cancel terminal). Cleared when the goal terminalizes. */
206
+ agentGoals = new Map();
207
+ /** Process start, for the served `status` uptime. */
208
+ startedAtMs = Date.now();
118
209
  /** SINGLE-FLIGHT guard for {@link deprovision} (INT-2/C): one in-flight teardown per
119
210
  * (name, lifecycleUid). The detached freeSlot teardown and every same-name-spawn nudge that
120
211
  * re-drives it JOIN one promise instead of launching a SECOND, concurrent teardown. Without it,
@@ -168,6 +259,7 @@ export class Manager {
168
259
  this.servers = opts.servers;
169
260
  this.name = opts.name ?? "manager";
170
261
  this.workspaceRoot = opts.workspaceRoot ?? findCotalRoot();
262
+ this.maxSessions = opts.maxSessions;
171
263
  this.secrets = opts.secretStore ?? workspaceSecretStore(this.workspaceRoot);
172
264
  this.installedExtensions = opts.installedExtensions ?? false;
173
265
  this.runtime = createRuntime(opts.runtime ?? "auto", `cotal-${this.space}`);
@@ -181,16 +273,19 @@ export class Manager {
181
273
  this.resumeAttemptId = opts.resumeAttemptId;
182
274
  this.resumeRequired = opts.resumeAttemptId !== undefined;
183
275
  this.resumeDurableCommitToken = opts.resumeDurableCommitToken;
184
- this.attach = new AttachEndpoint((name) => this.maintenanceState === "active" && !this.resumeRequired ? this.agents.get(name)?.handle : undefined, () => this.list(),
276
+ this.wsPort = opts.wsPort;
277
+ this.attach = new AttachEndpoint(() => this.list(),
185
278
  // Initial /feed replay for a connecting console: the current peer roster.
186
279
  () => [{ event: "roster", data: this.ep?.getRoster() ?? [] }], opts.consolePort ?? 0,
280
+ // P2 item 6: the console's mesh §13.6 session establisher — injected ONLY when a broker ws
281
+ // listener exists (cotal up allocated a wsPort). Never a second plane; it drives THE plane.
282
+ opts.wsPort !== undefined ? (name) => this.establishConsoleSession(name) : undefined,
187
283
  // Loopback unless the OPERATOR said otherwise. A broker *dial* address is not a manager *bind*
188
284
  // address: deriving one from the other breaks every topology where they differ (a manager
189
285
  // supervising a broker on another host cannot bind that host's address at all, and a failover
190
- // list's first entry need not be the server actually selected), and it would silently couple
191
- // terminal exposure to `up --host`. Exposure is therefore an explicit decision, which
192
- // `cotal up` passes down when it binds the broker somewhere reachable; every other caller — an
193
- // embedded Manager, a bare `cotal supervise` — keeps the loopback-only endpoint it always had.
286
+ // list's first entry need not be the server actually selected). Exposure is therefore an
287
+ // explicit decision; every other caller an embedded Manager, a bare `cotal supervise` —
288
+ // keeps the loopback-only console it always had.
194
289
  opts.attachHost ?? "127.0.0.1");
195
290
  }
196
291
  get runtimeKind() {
@@ -220,6 +315,24 @@ export class Manager {
220
315
  throw new Error(`space "${this.space}" has user-auth state on disk but no mesh registry entry - a user-mode manager needs the authoritative record (\`cotal up\` writes it before the control plane); \`cotal up --user-auth\` this space, or remove the stale ${userAuthStateDir(this.workspaceRoot, this.space)}`);
221
316
  if (this.userMode && !this.auth)
222
317
  throw new Error(`space "${this.space}" has user-auth state but no auth.json under ${authDir(this.workspaceRoot)} - the pre-flip manager still needs the space trust bundle; re-run \`cotal up --user-auth\` here`);
318
+ // P2 item 3 (SPEC 13.6 item 7): the LOGICAL instance id + serve identity PERSIST across restart
319
+ // (a space-scoped manager identity file under .cotal). A restart re-registers the SAME id with an
320
+ // ADVANCED epoch (the successor fences the predecessor); a fresh mint over a malformed file is
321
+ // refused loud (no-fallbacks - a restart never silently becomes a fresh instance). A second
322
+ // manager in a DIFFERENT workspace root is a DIFFERENT logical id by construction (its own state
323
+ // dir) - two managers in ONE space are two workspace roots.
324
+ {
325
+ const persisted = loadManagerInstanceIdentity(this.workspaceRoot, this.space);
326
+ if (persisted !== undefined) {
327
+ this.managerInstanceId = persisted.instanceId;
328
+ this.managerServeIdentity = persisted.serveIdentity;
329
+ }
330
+ else {
331
+ this.managerInstanceId = mintLifecycleUid();
332
+ this.managerServeIdentity = newIdentity();
333
+ saveManagerInstanceIdentity(this.workspaceRoot, this.space, { instanceId: this.managerInstanceId, serveIdentity: this.managerServeIdentity });
334
+ }
335
+ }
223
336
  let creds;
224
337
  let id;
225
338
  if (this.auth) {
@@ -244,9 +357,10 @@ export class Manager {
244
357
  creds,
245
358
  // The supervisor registers on the roster, and an authed presence-registering endpoint is
246
359
  // lifecycle-keyed (SPEC 13.1, fail-before-presence). The manager process is the top of its
247
- // own launch chain (the operator command IS its launcher), so it mints its incarnation's
248
- // uid here - one per supervisor process, never reused across restarts.
249
- lifecycleUid: mintLifecycleUid(),
360
+ // own launch chain (the operator command IS its launcher): its incarnation uid is the
361
+ // per-process `managerLifecycleUid` field (also the `managerInstance` audit coordinate on
362
+ // every static activation, Unit B).
363
+ lifecycleUid: this.managerLifecycleUid,
250
364
  // The supervisor serves control + watches presence; it never consumes chat/dm/task
251
365
  // (no message handler). consume:false avoids binding consumers it doesn't use — and
252
366
  // under auth avoids trying to bind its own DM/task durables that nothing pre-created.
@@ -263,38 +377,39 @@ export class Manager {
263
377
  this.ep.on("error", (e) => console.error(`! manager endpoint: ${e.message}`));
264
378
  await this.ep.start();
265
379
  await this.ep.setActivity(`supervisor (${this.runtime.kind})`);
266
- // Singleton guard: exactly one manager per space. Acquire the lease (atomic CAS create); if a live
267
- // manager already holds it, REFUSE to start (fail loud) rather than become a second supervisor that
268
- // queue-splits control with the incumbent. A crashed holder's lease auto-expires (bucket TTL).
269
- this.leaseInfo = { holder: this.ep.ref().id, runtime: this.runtime.kind, root: resolve(this.workspaceRoot), pid: process.pid };
380
+ // Per-instance liveness lease (P2 item 3 the old per-space singleton is DEMOTED per D9). Acquire
381
+ // THIS logical instance's own key (atomic CAS create). A DIFFERENT instance (a second manager in a
382
+ // second workspace root) has a distinct id a distinct key ⇒ it coexists; the create THROWS only
383
+ // when the SAME instance id is already live (a same-root double-start, or a restart racing the
384
+ // crashed predecessor's not-yet-expired key), and we REFUSE loud. A crashed holder's key auto-expires
385
+ // (bucket TTL). Losing this key later stops THIS instance only, never the space (security pin 6).
386
+ this.leaseInfo = { holder: this.ep.ref().id, instanceId: this.managerInstanceId, runtime: this.runtime.kind, root: resolve(this.workspaceRoot), pid: process.pid };
270
387
  try {
271
388
  this.leaseRevision = await this.ep.acquireManagerLease(this.leaseInfo);
272
389
  }
273
390
  catch (e) {
274
- // A live holder ⇒ refuse (the singleton point). Anything else (e.g. a KV/JS error) is a real
275
- // failure to surface, not a silent "held" — keep the cause so it isn't misread as a conflict.
391
+ // Our OWN instance id already holds a live key ⇒ refuse. Anything else (e.g. a KV/JS error) is a
392
+ // real failure to surface, not a silent "held" — keep the cause so it isn't misread as a conflict.
276
393
  const held = await this.ep.readManagerLease().catch(() => undefined);
277
394
  await this.ep.stop();
278
395
  await this.attach.stop();
279
396
  throw new Error(held
280
- ? `a manager already serves space "${this.space}" (id ${held.holder}, ${held.runtime}, pid ${held.pid}, root ${held.root}) - stop it first; one manager per space`
397
+ ? `manager instance ${this.managerInstanceId} already serves space "${this.space}" from this workspace root (${held.runtime}, pid ${held.pid}, root ${held.root}) - stop it first before restarting the same instance`
281
398
  : `could not acquire the manager lease for space "${this.space}": ${e.message}`);
282
399
  }
283
400
  this.leaseTimer = setInterval(() => { void this.renewLease(); }, MANAGER_LEASE_TTL_MS / 2);
284
401
  this.leaseTimer.unref?.();
285
- // Serve all three control tiers (P2a): self-service (no-name self stop/despawn), privileged
286
- // (start / own-child stop-despawn-attach / own definePersona), and admin (purge / cross-agent
287
- // stop-despawn-attach / cross-agent definePersona). The cred layer grants self-service to every
288
- // agent, privileged only to spawn-capable ones, and admin only to the manager's own profile
289
- // (no agent ever reaches it); the handler then routes by op↔tier (fail-closed on mismatch) so a
290
- // misrouted op is rejected before anything acts.
291
- // `boundReply` (closure (i)): each tier replies ONLY into the requester's own subtree
292
- // (`${reqSubject}.reply.…`), never the per-id `_INBOX`. This both keeps the confused-deputy guard
293
- // (a caller can't redirect a reply onto a peer's lane) AND lets the manager cred drop its position-1
294
- // inbox publish wildcard callers subscribe `ctl.<tier>.<id>.reply.>`, granted per tier they may call.
295
- this.ep.serveControl(CONTROL_PRIVILEGED, (req) => this.handle(req, CONTROL_PRIVILEGED), { boundReply: true });
296
- this.ep.serveControl(CONTROL_SELF_SERVICE, (req) => this.handle(req, CONTROL_SELF_SERVICE), { boundReply: true });
297
- this.ep.serveControl(CONTROL_ADMIN, (req) => this.handle(req, CONTROL_ADMIN), { boundReply: true });
402
+ // Unit B (static §13.1): ensure the two authority stores exist with their normative shape,
403
+ // then sweep the durable slot rows and reconcile re-drive any crashed activation/terminal
404
+ // (exact-op) and terminalize dead-but-active slots (F3 "no active orphan"). Runs ONLY under
405
+ // the just-acquired lease (a refused second manager must never sweep-terminal live slots) and
406
+ // BEFORE control serving, so no spawn races the reconciliation.
407
+ if (this.auth && !this.userMode)
408
+ await this.reconcileStaticLifecycles();
409
+ // P2 item 1 (1d): the manager serves NO ctl tiers - its whole control surface is the v0.4
410
+ // service endpoint registered below. The old three-tier rail (self/manager/admin) is deleted;
411
+ // `ctl.delivery`/`ctl.delivery-admin` (the delivery daemon) and `ctl.auth-admin` (the auth
412
+ // plane) are separate services and keep their rails.
298
413
  // D5 slice 5 class 2: the manager is the CLASS-2 RENEWAL OWNER — the one control-plane process
299
414
  // that is resident in EVERY mesh mode (foreground `up`, `up --detach`, same-root refresh) and
300
415
  // holds the signer. Ordered initial pass NOW (ensureControlPlane starts delivery BEFORE the
@@ -306,6 +421,24 @@ export class Manager {
306
421
  this.credRenewTimer = setInterval(() => { void this.renewDaemonCreds(); }, (STANDING_RENEWABLE_TTL_SEC / 2) * 1000);
307
422
  this.credRenewTimer.unref?.();
308
423
  }
424
+ // P2 item 1: register the manager as an ordinary v0.4 `service` endpoint (SPEC §13.7/§13.9)
425
+ // and serve its typed command surface on the ep rails - since 1d the ONLY control door, in
426
+ // EVERY mesh mode. Static + user meshes mint the scoped executor + endpoint-serve credential;
427
+ // an open mesh runs the same gate/registration ceremony over bare connections and never mints
428
+ // (there is no credential system - the broker enforces nothing, matching the old open-mesh ctl
429
+ // trust). Fail-loud: a manager that cannot register does not start half-registered.
430
+ await this.registerManagerService();
431
+ // P2 item 2: stand up the standing goal-writer connection for spawn-as-action — AFTER
432
+ // registration (it writes this endpoint's goal facts/records), disjoint from the serve cred.
433
+ await this.startGoalWriter();
434
+ // P2 item 6: stand up the ONE §13.6 session plane for `attach` — AFTER registration too (it
435
+ // rides the serve grant's epoch + the family-staged session-ledger cred), on its own standing
436
+ // connection disjoint from both the serve and goal-writer creds.
437
+ await this.startSessionPlane();
438
+ // P2 item 2 must-5 Q-B: reconcile any accepted-but-unterminal goals inherited from a predecessor
439
+ // BEFORE spawn-as-action begins accepting (the goalReconcileDone gate) — a fresh incarnation
440
+ // never drops a goal a dead predecessor accepted. Never fatal; the gate opens either way.
441
+ await this.reconcileGoalIndex();
309
442
  // Plane-3 (durable backstop) is NOT the manager's job — the manager only manages agent lifecycle.
310
443
  // The server-side delivery daemon hosts the fan-out writer + trusted reader, owns the durable
311
444
  // membership registry, and serves the runtime durable join/leave/list ops (on `ctl.delivery`). The
@@ -364,6 +497,97 @@ export class Manager {
364
497
  // `writeRenewalRecord` redacts the ephemeral fingerprint at the persistence boundary (covering
365
498
  // the `doctor auth --fix` writer too), so the results pass straight through.
366
499
  writeRenewalRecord(this.workspaceRoot, { ts: new Date().toISOString(), owner: "manager", results, adoption });
500
+ this.warnOnSystemCredExpiry();
501
+ // F5(b) (Unit B): the MANAGER is the renewal owner for its managed-static agent creds —
502
+ // supervisor-side PUSH remint for recorded LIVE slots (the child JWT is never proof of
503
+ // incarnation; a copied credential cannot drive this and is stranded at its own row's TTL).
504
+ // Same class-2 mechanics as the daemon creds: re-sign the file for the SAME nkey; the
505
+ // agent endpoint's 75% source re-read adopts it.
506
+ if (!this.userMode) {
507
+ for (const a of [...this.agents.values()]) {
508
+ // The `terminalizing` test here is an OPTIMISATION, NOT THE GUARD. There are awaits below
509
+ // it, so an agent can latch mid-iteration and this filter will have already let it
510
+ // through — what actually refuses is the same test at the top of
511
+ // {@link renewManagedStaticCred}, which every renewal on this path goes through.
512
+ // COUPLING: deleting or weakening that check silently promotes this line from an
513
+ // optimisation into the whole guard, and nothing fails at the moment of the change.
514
+ if (a.userOwner || a.terminalizing || !a.seed || !a.secretPaths?.creds)
515
+ continue;
516
+ try {
517
+ const stored = await this.secrets.get(agentSecretKeyForFile(a.secretPaths.creds));
518
+ if (stored === undefined)
519
+ continue; // no materialized cred (never minted here) - nothing to renew
520
+ const health = inspectCredHealth(stored);
521
+ if (health.state === "healthy")
522
+ continue;
523
+ if (health.state === "unbounded" || health.state === "unreadable") {
524
+ console.error(`! managed cred renewal ${a.name}: credential is ${health.state}${health.error ? ` (${health.error})` : ""} - not renewed (a pre-TTL credential stays as minted until respawn)`);
525
+ continue;
526
+ }
527
+ await this.renewManagedStaticCred(a);
528
+ }
529
+ catch (e) {
530
+ console.error(`! managed cred renewal ${a.name}: ${e.message} - the agent dies loud at this cred's expiry unless it is reminted`);
531
+ }
532
+ }
533
+ }
534
+ // P2 item 1 (checklist 7): the manager is the `endpoint-serve` renewal owner for its OWN
535
+ // service credential — re-mint the SAME serve identity with a fresh bounded exp THROUGH the
536
+ // §13.1 mint fence over a scoped one-shot executor (every renewal stages a distinct ledger
537
+ // row and wins the gate CAS; never the standing connection). The serve connection's
538
+ // authenticator presents the refreshed credential on its next (re)connect.
539
+ if (this.serviceServe?.creds && this.auth) {
540
+ const s = this.serviceServe;
541
+ const authRef = this.auth;
542
+ try {
543
+ const health = inspectCredHealth(this.serviceServe.creds);
544
+ if (health.state !== "healthy") {
545
+ s.creds = await this.withEndpointServeExecutor(({ authKv }) => mintCreds(authRef, s.identity, "endpoint-serve", {
546
+ serveIssuance: serveIssuanceGateKv(authKv, this.space, { endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId }),
547
+ endpointServe: s.grant,
548
+ }));
549
+ }
550
+ }
551
+ catch (e) {
552
+ console.error(`! endpoint-serve renewal: ${e.message} - the manager's service endpoint dies loud at this cred's expiry unless it is re-registered`);
553
+ }
554
+ }
555
+ // P2 item 2 must-5 (b): the manager is also the goal-writer's renewal owner — re-mint the SAME
556
+ // goal-writer nkey with a fresh bounded exp AND re-stage its new credId into the §13.1 family,
557
+ // through the scoped executor (never the standing seed). Without this the standing goal-writer
558
+ // connection dies at its TTL and spawn-as-action stops accepting until a restart. The
559
+ // connection's authenticator presents the refreshed credential on its next (re)connect.
560
+ if (this.goalWriter && this.goalWriterCreds && this.auth) {
561
+ const gw = this.goalWriter;
562
+ try {
563
+ if (inspectCredHealth(this.goalWriterCreds).state !== "healthy") {
564
+ const fresh = await this.withEndpointServeExecutor(({ authKv }) => this.mintAndStageGoalWriter(authKv));
565
+ this.goalWriterCreds = fresh;
566
+ gw.creds = fresh;
567
+ }
568
+ }
569
+ catch (e) {
570
+ console.error(`! goal-writer renewal: ${e.message} - spawn-as-action stops accepting at this cred's expiry unless the manager restarts`);
571
+ }
572
+ }
573
+ // P2 item 6: the manager is also the session-ledger's renewal owner — re-mint the SAME nkey
574
+ // with a fresh bounded exp AND re-stage its new credId into the §13.1 family, through the
575
+ // scoped executor. Without this the standing session-ledger connection dies at its TTL and
576
+ // `attach` stops establishing sessions until a restart. The connection's authenticator presents
577
+ // the refreshed credential on its next (re)connect.
578
+ if (this.sessionLedgerConn && this.sessionLedgerCreds && this.auth) {
579
+ const sw = this.sessionLedgerConn;
580
+ try {
581
+ if (inspectCredHealth(this.sessionLedgerCreds).state !== "healthy") {
582
+ const fresh = await this.withEndpointServeExecutor(({ authKv }) => this.mintAndStageSessionLedger(authKv));
583
+ this.sessionLedgerCreds = fresh;
584
+ sw.creds = fresh;
585
+ }
586
+ }
587
+ catch (e) {
588
+ console.error(`! session-ledger renewal: ${e.message} - attach stops establishing sessions at this cred's expiry unless the manager restarts`);
589
+ }
590
+ }
367
591
  }
368
592
  catch (e) {
369
593
  console.error(`! credential renewal pass failed: ${e.message}`);
@@ -372,6 +596,38 @@ export class Manager {
372
596
  release();
373
597
  }
374
598
  }
599
+ /** Warn, on every renewal pass, when a $SYS credential is at or past its renewal point.
600
+ *
601
+ * The manager is the renewal owner for every credential it CAN re-sign, and these two are the ones
602
+ * it cannot: they are `rotation-renewed`, so no resident process re-mints them and they simply die
603
+ * on their 30-day horizon. Before this, a mesh that never ran `doctor auth` got no signal at all,
604
+ * it discovered the expiry as an "Authorization Violation" in the delivery log and a refused
605
+ * membership adoption, weeks after the warning would have been actionable (#338). The pass runs
606
+ * every half-TTL of the 24h class, so this repeats about twice a day for the ~7 days between the
607
+ * renewal point and expiry: loud enough to be seen, bounded enough not to be noise.
608
+ *
609
+ * Diagnostic only, and deliberately non-fatal: renewal is an operator action (`cotal down` then
610
+ * `cotal up --rotate-sys`, which needs a broker restart), so the manager must report it, never
611
+ * attempt it. An absent file is the unprovisioned space, reported by the daemon that needs it. */
612
+ warnOnSystemCredExpiry() {
613
+ for (const file of SYSTEM_CREDS_FILES) {
614
+ const path = join(this.workspaceRoot, ".cotal", file);
615
+ if (!existsSync(path))
616
+ continue;
617
+ let health;
618
+ try {
619
+ health = inspectCredHealth(readFileSync(path, "utf8"));
620
+ }
621
+ catch {
622
+ continue; // an unreadable $SYS file is the daemon's loud failure, not a renewal-pass crash
623
+ }
624
+ const when = health.exp ? new Date(health.exp * 1000).toISOString() : "an unknown date";
625
+ if (health.state === "expired")
626
+ console.error(`! $SYS credential ${file} EXPIRED ${when} - the broker denies it, and NOTHING renews it in place (it is rotation-renewed). Live eviction and the membership feed stay down until: \`cotal down\` then \`cotal up --rotate-sys\` (agents, creds and data survive)`);
627
+ else if (health.state === "near-expiry")
628
+ console.error(`! $SYS credential ${file} expires ${when} and nothing renews it in place (it is rotation-renewed) - schedule: \`cotal down\` then \`cotal up --rotate-sys\` (agents, creds and data survive)`);
629
+ }
630
+ }
375
631
  /** Admit one lifecycle/control operation while active. The synchronous increment is the fence:
376
632
  * preserveState flips state before its first await, so work is either counted or rejected. */
377
633
  beginLifecycle(resumeOperation = false) {
@@ -679,19 +935,30 @@ export class Manager {
679
935
  : { owner: DEV_OWNER, actor: a.id };
680
936
  if (!principal)
681
937
  throw new Error(`managed agent ${a.name} has an invalid principal ${a.id}`);
682
- const files = agentSecretFilePaths(this.workspaceRoot, a.name);
938
+ // The RECORDED secret-family paths (set at spawn or adoption) — never a re-derivation by name:
939
+ // under mixed generations (a name-keyed pre-split incarnation adopted by this manager) the
940
+ // recorded path is the only truth, and re-deriving would preserve a family that isn't there.
941
+ const files = a.secretPaths;
683
942
  const identity = a.userOwner
684
- ? {
685
- mode: "user",
686
- owner: principal.owner,
687
- actor: principal.actor,
688
- lifecycleUid: a.lifecycleUid,
689
- actorToken: { kind: "file", path: files.actorToken, sha256: this.fileDigestOrEmpty(files.actorToken) },
690
- sentinelCredential: { kind: "file", path: files.sentinelCreds, sha256: this.fileDigestOrEmpty(files.sentinelCreds) },
691
- health: { kind: "file", path: files.health },
692
- }
943
+ ? (() => {
944
+ if (!files?.actorToken || !files.sentinelCreds || !files.health)
945
+ throw new Error(`managed agent ${a.name} is user-mode but its secret-family paths were not recorded`);
946
+ return {
947
+ mode: "user",
948
+ owner: principal.owner,
949
+ actor: principal.actor,
950
+ lifecycleUid: a.lifecycleUid,
951
+ actorToken: { kind: "file", path: files.actorToken, sha256: this.fileDigestOrEmpty(files.actorToken) },
952
+ sentinelCredential: { kind: "file", path: files.sentinelCreds, sha256: this.fileDigestOrEmpty(files.sentinelCreds) },
953
+ health: { kind: "file", path: files.health },
954
+ };
955
+ })()
693
956
  : this.auth
694
- ? { mode: "static", id: principal.actor, lifecycleUid: a.lifecycleUid, credential: { kind: "file", path: files.creds, sha256: this.fileDigestOrEmpty(files.creds) } }
957
+ ? (() => {
958
+ if (!files?.creds)
959
+ throw new Error(`managed agent ${a.name} is static-auth but its credential path was not recorded`);
960
+ return { mode: "static", id: principal.actor, lifecycleUid: a.lifecycleUid, credential: { kind: "file", path: files.creds, sha256: this.fileDigestOrEmpty(files.creds) } };
961
+ })()
695
962
  : { mode: "open", id: principal.actor, lifecycleUid: a.lifecycleUid };
696
963
  const dependencies = [a.launch.source.configPath];
697
964
  if (a.launch.source.kind === "manifest" && a.launch.source.runId)
@@ -779,14 +1046,42 @@ export class Manager {
779
1046
  // A signal after a partial preservation must never fall back into destructive teardown.
780
1047
  await this.stopRetainedAgentsOnExit();
781
1048
  }
782
- await this.ep.releaseManagerLease(this.leaseRevision);
1049
+ await this.ep.releaseManagerLease(this.managerInstanceId, this.leaseRevision);
1050
+ await this.stopServiceServe();
1051
+ await this.stopGoalWriter();
1052
+ await this.stopSessionPlane();
783
1053
  await this.ep.stop();
784
1054
  await this.attach.stop();
785
1055
  }
786
- /** Refresh the singleton lease before the bucket TTL expires it. On loss (missed the TTL, or another
787
- * manager took over after a gap) FAIL CLOSED: stop serving control at once so we can't double-process
788
- * with the new holder, and exit. We deliberately do NOT re-acquire (a replacement may already be live
789
- * while we'd still be serving) and do NOT release the key — it now belongs to that replacement. */
1056
+ /** Stop the v0.4 service-endpoint serve loop (drain subscriptions, await in-flight handlers)
1057
+ * and drop its dedicated connection. Best-effort by design both exit paths (graceful stop,
1058
+ * lease-loss fail-close) must complete their remaining teardown even if the broker is gone. */
1059
+ async stopServiceServe() {
1060
+ const s = this.serviceServe;
1061
+ if (!s)
1062
+ return;
1063
+ this.serviceServe = undefined;
1064
+ try {
1065
+ await s.handle.stop();
1066
+ }
1067
+ catch { /* best effort */ }
1068
+ try {
1069
+ await s.nc.drain();
1070
+ }
1071
+ catch {
1072
+ try {
1073
+ s.nc.close();
1074
+ }
1075
+ catch { /* best effort */ }
1076
+ }
1077
+ }
1078
+ /** Refresh THIS instance's liveness lease before the bucket TTL expires it. On loss (missed the TTL —
1079
+ * this instance stalled past the renew window) FAIL CLOSED for THIS INSTANCE ONLY: stop serving +
1080
+ * tear down OUR managed agents + exit, so a stalled instance can't keep double-processing under a key
1081
+ * a same-id restart may re-acquire. Keyed per instance, so this NEVER frees or touches a sibling
1082
+ * manager's key and NEVER freezes the space (security pin 6) — the sibling keeps serving. We do NOT
1083
+ * re-acquire (a same-id restart may already be live) and do NOT release the key (it may be the
1084
+ * restart's). A DIFFERENT instance losing ITS key is a separate, independent event. */
790
1085
  async renewLease() {
791
1086
  try {
792
1087
  if (!this.leaseInfo || this.leaseRevision === undefined)
@@ -794,7 +1089,7 @@ export class Manager {
794
1089
  this.leaseRevision = await this.ep.renewManagerLease(this.leaseInfo, this.leaseRevision);
795
1090
  }
796
1091
  catch (e) {
797
- console.error(`! manager lost its singleton lease for space "${this.space}" (${e.message}) - shutting down to avoid two managers serving it`);
1092
+ console.error(`! manager instance ${this.managerInstanceId} lost its liveness lease for space "${this.space}" (${e.message}) - shutting down THIS instance (its serving only; siblings keep the space)`);
798
1093
  if (this.leaseTimer)
799
1094
  clearInterval(this.leaseTimer);
800
1095
  // Tear down our managed agents' footprints too (#159 B2) — this exit path leaks them otherwise. Do
@@ -806,6 +1101,9 @@ export class Manager {
806
1101
  await this.stopRetainedAgentsOnExit();
807
1102
  }
808
1103
  catch { /* best effort */ }
1104
+ await this.stopServiceServe();
1105
+ await this.stopGoalWriter();
1106
+ await this.stopSessionPlane();
809
1107
  try {
810
1108
  await this.ep.stop();
811
1109
  }
@@ -817,13 +1115,11 @@ export class Manager {
817
1115
  process.exit(1);
818
1116
  }
819
1117
  }
820
- async handle(req, tier) {
821
- if (req.op === "finalizeResume") {
822
- if (tier !== CONTROL_ADMIN)
823
- return { ok: false, error: "finalizeResume is admin-only; not allowed on this control subject" };
1118
+ async opFinalizeResume(rawArgs) {
1119
+ {
824
1120
  let args;
825
1121
  try {
826
- args = parseResumeFinalizeArgs(req.args);
1122
+ args = parseResumeFinalizeArgs(rawArgs);
827
1123
  }
828
1124
  catch (e) {
829
1125
  return { ok: false, error: e.message };
@@ -853,16 +1149,27 @@ export class Manager {
853
1149
  if (managed)
854
1150
  managed.suppressCleanup = false;
855
1151
  }
1152
+ // Unit B (F3, distsys/security CONDITIONAL @ 9e13648): the boot sweep DEFERRED every active
1153
+ // slot while a resume was pending (it could not know which would be adopted). Now adoption
1154
+ // is complete and `this.agents` is EXACTLY the adopted set, so re-sweep to terminalize any
1155
+ // active slot the resume did NOT claim — a durable ACTIVE ORPHAN (crashed after slot->active
1156
+ // before agents.set, then not in the resumed inventory). This runs while `resumeRequired` is
1157
+ // still true, so no ordinary spawn can race it (beginLifecycle refuses non-resume ops), and
1158
+ // it closes both the alias wedge AND the F5(a) gap (the orphan's principal enters
1159
+ // retiredPrincipals, so a copied JWT is refused). Best-effort + loud: a sweep failure must
1160
+ // not fail the finalize (the next non-resume boot re-drives it), but it is never swallowed.
1161
+ if (this.auth && !this.userMode)
1162
+ await this.reconcileStaticLifecycles(true).catch((e) => console.error(`! post-resume static reconcile: ${e.message} - a durable active orphan may still wedge its alias until the next non-resume restart`));
856
1163
  this.resumeFinalized = true;
857
1164
  this.resumeRequired = false;
858
1165
  return { ok: true, data: { attemptId: args.attemptId, state: "active" } };
859
1166
  }
860
- if (req.op === "commitResume") {
861
- if (tier !== CONTROL_ADMIN)
862
- return { ok: false, error: "commitResume is admin-only; not allowed on this control subject" };
1167
+ }
1168
+ async opCommitResume(rawArgs) {
1169
+ {
863
1170
  let attemptId;
864
1171
  try {
865
- attemptId = parseResumeCommitArgs(req.args).attemptId;
1172
+ attemptId = parseResumeCommitArgs(rawArgs).attemptId;
866
1173
  }
867
1174
  catch (e) {
868
1175
  return { ok: false, error: e.message };
@@ -890,11 +1197,11 @@ export class Manager {
890
1197
  this.resumeCommitTask = undefined;
891
1198
  }
892
1199
  }
893
- if (req.op === "resumePreserved") {
894
- if (tier !== CONTROL_ADMIN)
895
- return { ok: false, error: "resumePreserved is admin-only; not allowed on this control subject" };
1200
+ }
1201
+ async opResumePreserved(rawArgs) {
1202
+ {
896
1203
  try {
897
- const args = parseResumeControlArgs(req.args);
1204
+ const args = parseResumeControlArgs(rawArgs);
898
1205
  const inventoryDigest = createHash("sha256").update(JSON.stringify(args.inventory)).digest("hex");
899
1206
  if (!this.resumeAttemptId)
900
1207
  return { ok: false, error: "resumePreserved requires a manager started with --resume-attempt" };
@@ -925,44 +1232,220 @@ export class Manager {
925
1232
  return { ok: false, error: e.message };
926
1233
  }
927
1234
  }
928
- if (req.op === "preparePreservation" || req.op === "commitPreservation" || req.op === "abortPreservation") {
929
- if (tier !== CONTROL_ADMIN)
930
- return { ok: false, error: `${req.op} is admin-only; not allowed on this control subject` };
931
- if (this.resumeRequired)
932
- return { ok: false, error: this.maintenanceError() };
933
- const attemptId = String(req.args?.attemptId ?? "").trim();
934
- if (!attemptId)
935
- return { ok: false, error: `${req.op} requires attemptId` };
936
- try {
937
- if (req.op === "abortPreservation") {
938
- this.abortPreservation(attemptId);
939
- return { ok: true, data: { attemptId, state: "active" } };
940
- }
941
- const result = req.op === "preparePreservation"
942
- ? await this.preparePreservation(attemptId)
943
- : await this.commitPreservation(attemptId);
944
- return result.ok
945
- ? { ok: true, data: result }
946
- : {
947
- ok: false,
948
- data: result,
949
- error: `preservation incomplete: ${result.failures.map((f) => `${f.name}: ${f.error}`).join("; ")}`,
950
- };
951
- }
952
- catch (e) {
953
- return { ok: false, error: e.message };
1235
+ }
1236
+ async opPreservationCtl(op, rawArgs) {
1237
+ if (this.resumeRequired)
1238
+ return { ok: false, error: this.maintenanceError() };
1239
+ const attemptId = String(rawArgs?.attemptId ?? "").trim();
1240
+ if (!attemptId)
1241
+ return { ok: false, error: `${op} requires attemptId` };
1242
+ try {
1243
+ if (op === "abortPreservation") {
1244
+ this.abortPreservation(attemptId);
1245
+ return { ok: true, data: { attemptId, state: "active" } };
954
1246
  }
1247
+ const result = op === "preparePreservation"
1248
+ ? await this.preparePreservation(attemptId)
1249
+ : await this.commitPreservation(attemptId);
1250
+ return result.ok
1251
+ ? { ok: true, data: result }
1252
+ : {
1253
+ ok: false,
1254
+ data: result,
1255
+ error: `preservation incomplete: ${result.failures.map((f) => `${f.name}: ${f.error}`).join("; ")}`,
1256
+ };
955
1257
  }
1258
+ catch (e) {
1259
+ return { ok: false, error: e.message };
1260
+ }
1261
+ }
1262
+ /** The ONE shared control-admission chokepoint (P2 item 1, checklist 3/8) BOTH dispatch doors
1263
+ * run — the v0.3 `ctl` door ({@link handle}) and the v0.4 `ep` service handlers
1264
+ * ({@link serveGated}): the maintenance/resume fence (`beginLifecycle`: a resume-pending or
1265
+ * non-active manager accepts no ordinary control work) and then the F5(a) membership gate
1266
+ * ({@link lifecycleMembershipRefusal}: a retiring/terminalizing/retired managed incarnation's
1267
+ * AUTHENTICATED principal holds no control authority even with a valid JWT). Refusal carries
1268
+ * WHICH fence refused so the ep door can map onto the §13.3 catalog; admission returns the
1269
+ * accepted-work release. Never re-implemented per door — a fence on one door is a bypass. */
1270
+ admitControl(caller) {
956
1271
  const release = this.beginLifecycle();
957
1272
  if (!release)
958
- return { ok: false, error: this.maintenanceError() };
1273
+ return { refusal: this.maintenanceError(), fence: "maintenance" };
1274
+ const membership = this.lifecycleMembershipRefusal(caller);
1275
+ if (membership) {
1276
+ release();
1277
+ return { refusal: membership, fence: "membership" };
1278
+ }
1279
+ return { release };
1280
+ }
1281
+ /** Run one v0.4 service-command handler through the SHARED admission chokepoint
1282
+ * ({@link admitControl}) on the broker-authenticated caller principal, mapping the two fences
1283
+ * onto the §13.3 catalog: maintenance/resume → `unavailable`, F5(a) membership →
1284
+ * `permission-denied`. The serve boundary publishes the structured error reply. */
1285
+ async serveGated(ctx, fn) {
1286
+ const caller = principalKey(ctx.subject.caller.owner, ctx.subject.caller.actor).key;
1287
+ const admission = this.admitControl(caller);
1288
+ if (admission.refusal !== undefined)
1289
+ throw new EpEnvelopeError(admission.fence === "membership" ? "permission-denied" : "unavailable", admission.refusal);
959
1290
  try {
960
- return await this.handleActive(req, tier);
1291
+ return await fn();
961
1292
  }
962
1293
  finally {
963
- release();
1294
+ admission.release();
1295
+ }
1296
+ }
1297
+ /** The ep door's ADMIN flag for a caller (the 1c tier refinement). Static mesh: `true` — the
1298
+ * admin-grade rows (any-mode despawn/attach, the `manager.admin` family, `launch`) are minted
1299
+ * only into operator instruments (§13.2: `any` is operator-policy-mintable; the agent/spawn
1300
+ * rollups never carry them), so REACHING the handler is holding the admin tier, exactly as
1301
+ * holding `ctl.<admin>` is today. User mesh: the caller's CURRENT ledger scope must carry
1302
+ * `admin` — the same fresh-read authority {@link psOwnerFilter} consults, so a revoked scope
1303
+ * demotes the very next call even on a still-valid bearer. Fail-closed: an unreadable ledger
1304
+ * authorizes nothing. NAMED RESIDUAL (critic, 1c.2b): the static `true` has no serve-time
1305
+ * re-check — a LEAKED static admin instrument keeps its reach until the credential's bounded
1306
+ * TTL (the one-shot 5-minute profile), the same static-revoke≠reconnect-death class ruled
1307
+ * across this campaign; static revocation is the TTL, not a ledger. */
1308
+ async epAdminReach(caller) {
1309
+ if (!this.userMode)
1310
+ return true;
1311
+ const key = parsePrincipalKey(caller);
1312
+ if (!key)
1313
+ return false;
1314
+ try {
1315
+ const scope = await resolveAuthProvider().actorScope({
1316
+ dir: userAuthStateDir(this.workspaceRoot, this.space),
1317
+ owner: key.owner,
1318
+ actor: key.actor,
1319
+ });
1320
+ return scope?.includes("admin") === true;
1321
+ }
1322
+ catch {
1323
+ return false;
964
1324
  }
965
1325
  }
1326
+ /** A targeted request's admin flag: mode `any` (the operator instrument's cross-agent form,
1327
+ * rev 3) resolves through {@link epAdminReach}; a user-mode any-mode caller whose CURRENT
1328
+ * ledger row lost `admin` since its rows were minted refuses loud rather than silently
1329
+ * downgrading to the owner path (the request's declared mode is honored or denied, never
1330
+ * reinterpreted). Owner mode is always the privileged (own-domain) path. */
1331
+ async epAnyModeAdmin(ctx) {
1332
+ if (ctx.subject.target?.mode !== "any")
1333
+ return false;
1334
+ if (!(await this.epAdminReach(principalKey(ctx.subject.caller.owner, ctx.subject.caller.actor).key)))
1335
+ 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)`);
1336
+ return true;
1337
+ }
1338
+ /** The v0.4 typed command table (P2 item 1, slice 1b): every ordinary handler runs the SHARED
1339
+ * admission chokepoint ({@link serveGated}) and then delegates to the SAME op core the ctl
1340
+ * door dispatches (checklist 8: one core, two thin doors). The resume/preservation family
1341
+ * deliberately BYPASSES serveGated — exactly as it sits before {@link admitControl} on the ctl
1342
+ * door (those ops must run while `resumeRequired` fences ordinary work) — riding its own state
1343
+ * fences; its ep gate is the admin-grade `manager.admin` capability grant (the 1b rule: static
1344
+ * admin-class commands are capability-gated + untargeted, never a fabricated ledger mode).
1345
+ *
1346
+ * TIER SEMANTICS on the ep door (the 1c grant-migration table): the tier lives in the CALLER'S
1347
+ * GRANT, refined per-op exactly as the ctl doors refine their subject tier. Owner-mode
1348
+ * `despawn`/`attach` keep the privileged semantics (`admin=false`, own-domain via
1349
+ * {@link authorizeNamed}) — every spawn-capable agent holds those rows. ANY-mode requests are
1350
+ * the operator instrument's cross-agent reach (rev 3): the any-mode subject row is mintable
1351
+ * only under operator policy (§13.2), so on a static mesh holding it IS the admin tier, and in
1352
+ * user mode the caller's CURRENT ledger scope must still carry `admin`
1353
+ * ({@link epAdminReach}, the same fresh-read authority `psOwnerFilter` consults). The
1354
+ * `manager.admin` family (purge + the resume/preservation ops) is capability-gated at mint AND
1355
+ * re-checked at serve time via {@link epAdminReach} (the `adminGated` wrapper) so a user's
1356
+ * revoked scope demotes the next call. `launch` is OWNER-EQUALITY on this door for everyone
1357
+ * (freelance HIGH #2): the deploy path is its only consumer and stamps the caller's own owner,
1358
+ * so cross-owner launch was a ctl-tier incidental never exercised, and keying it on the actor's
1359
+ * ledger scope broke the deployer-view attenuation - uniform owner-equality is the safe tier.
1360
+ * TWO DELIBERATE NARROWINGS vs the ctl doors (NOT bit-exact parity, panel-accepted): (1)
1361
+ * `define-persona` is `admin=false` for everyone (own-persona discipline; no ep consumer needs
1362
+ * cross-owner persona writes - an operator redefines via config, not the wire), where the ctl
1363
+ * admin tier allowed operator cross-owner redefine; (2) launch is owner-equality-only, above.
1364
+ * Both are least-privilege reductions, never widenings. */
1365
+ managerServiceDefs() {
1366
+ const args = (ctx) => (ctx.request.args ?? {});
1367
+ const callerOf = (ctx) => principalKey(ctx.subject.caller.owner, ctx.subject.caller.actor).key;
1368
+ // A ctl-core failure reply becomes the §13.3 structured error the serve boundary publishes.
1369
+ // The data half of a failure reply (e.g. a degraded resume result) rides the error MESSAGE
1370
+ // only — the item-2 action model gives failures a typed channel.
1371
+ const unwrap = (r) => {
1372
+ if (!r.ok)
1373
+ throw new EpEnvelopeError("failed-precondition", r.error ?? "the operation failed");
1374
+ return r.data;
1375
+ };
1376
+ // The admin-family serve gate (1c.2c, security4's hardening): every `manager.admin`-class
1377
+ // command re-checks operator reach AT SERVE TIME - static: true (the mint boundary already
1378
+ // gates the rows to instruments); user mesh: the caller's CURRENT ledger scope must still
1379
+ // carry `admin` ({@link epAdminReach}'s fresh read), so a revoked scope demotes the very next
1380
+ // call instead of riding the bearer's remaining JWT-row lifetime. The resume family keeps its
1381
+ // serveGated BYPASS (those ops must run while the maintenance fence holds) but not the gate.
1382
+ const adminGated = async (ctx, fn) => {
1383
+ if (!(await this.epAdminReach(callerOf(ctx))))
1384
+ throw new EpEnvelopeError("permission-denied", `${ctx.subject.command} is operator reach; the caller's current ledger grant does not carry "admin" (SPEC 13.2)`);
1385
+ return fn();
1386
+ };
1387
+ const targetAgent = (ctx) => {
1388
+ const t = ctx.request.target; // targeted commands only: the serve boundary enforced body-target presence + fresh currency
1389
+ const a = this.findManagedByTarget(t);
1390
+ if (!a)
1391
+ throw new EpEnvelopeError("expired", `target ${t.owner}.${t.actor} (lifecycle ${t.lifecycleUid}) is not a live managed agent of this manager`);
1392
+ return a;
1393
+ };
1394
+ return managerCommandDefs({
1395
+ status: (ctx) => this.serveGated(ctx, () => this.managerStatusData()),
1396
+ ps: (ctx) => this.serveGated(ctx, async () => this.list(await this.psOwnerFilter(callerOf(ctx), false))),
1397
+ inspect: (ctx) => this.serveGated(ctx, async () => {
1398
+ const name = String(args(ctx).name ?? "").trim();
1399
+ const row = this.list(await this.psOwnerFilter(callerOf(ctx), false)).find((x) => x.name === name);
1400
+ if (!row)
1401
+ throw new EpEnvelopeError("not-found", `no agent "${name}"`);
1402
+ return row;
1403
+ }),
1404
+ models: (ctx) => this.serveGated(ctx, async () => {
1405
+ const data = unwrap(await this.opModels(args(ctx)));
1406
+ return { catalogs: Array.isArray(data) ? data : [data] };
1407
+ }),
1408
+ // P2 item 2: `spawn` is an ACTION - accept a goal + reply the acceptance floor payload, drive
1409
+ // progress + terminal off-handler (no ~30s block). The blocking reply path is gone (pin 8).
1410
+ spawn: (ctx) => this.serveGated(ctx, () => this.serveSpawnGoal(ctx, (h) => this.opStart(args(ctx), callerOf(ctx), h))),
1411
+ despawn: (ctx) => this.serveGated(ctx, async () => {
1412
+ const a = targetAgent(ctx);
1413
+ const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx));
1414
+ if (denied)
1415
+ throw new EpEnvelopeError("permission-denied", denied);
1416
+ return unwrap(this.despawnAuthorized(a, args(ctx).graceful !== false, true));
1417
+ }),
1418
+ attach: (ctx) => this.serveGated(ctx, async () => {
1419
+ const a = targetAgent(ctx);
1420
+ const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx));
1421
+ if (denied)
1422
+ throw new EpEnvelopeError("permission-denied", denied);
1423
+ return unwrap(await this.attachAuthorized(a, ctx.subject.caller));
1424
+ }),
1425
+ stopSelf: (ctx) => this.serveGated(ctx, () => unwrap(this.opStopSelf(callerOf(ctx), args(ctx)))),
1426
+ definePersona: (ctx) => this.serveGated(ctx, () => unwrap(this.opDefinePersona(args(ctx), callerOf(ctx), false))),
1427
+ purge: (ctx) => this.serveGated(ctx, () => adminGated(ctx, async () => unwrap(await this.opPurge(args(ctx), callerOf(ctx))))),
1428
+ // launch is OWNER-EQUALITY on the ep door for every caller (freelance HIGH #2): the deploy
1429
+ // path is the only launch consumer and its spec stamps the CALLER's own owner, so
1430
+ // owner-equality always holds for a legitimate deploy; cross-owner launch was a ctl
1431
+ // admin-tier INCIDENTAL never exercised by a real flow (static is single-owner, so the flag
1432
+ // is a no-op there). Keying admin on epAdminReach read the ACTOR's ledger scope, which does
1433
+ // NOT reflect the deployer VIEW's privileged-tier attenuation - an admin user's stolen
1434
+ // deployer bearer would then bypass owner-equality (operator launch) despite the view holding
1435
+ // no admin rows. Uniform owner-equality removes that divergence in the least-privilege
1436
+ // direction (consistent with the delta-(b) tier narrowing the panel endorsed).
1437
+ // P2 item 2 (ruling 3): manifest `launch` is an ACTION through the SAME chokepoint as spawn -
1438
+ // the manifest resolve + owner-equality authz run in opLaunch's accept path, then the goal
1439
+ // drives progress + terminal. The acceptance floor is the allocated identity + goal coords.
1440
+ launch: (ctx) => this.serveGated(ctx, () => this.serveSpawnGoal(ctx, (h) => this.opLaunch(args(ctx), callerOf(ctx), false, h))),
1441
+ resumePreserved: (ctx) => adminGated(ctx, async () => unwrap(await this.opResumePreserved(args(ctx)))),
1442
+ commitResume: (ctx) => adminGated(ctx, async () => unwrap(await this.opCommitResume(args(ctx)))),
1443
+ finalizeResume: (ctx) => adminGated(ctx, async () => unwrap(await this.opFinalizeResume(args(ctx)))),
1444
+ preparePreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("preparePreservation", args(ctx)))),
1445
+ commitPreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("commitPreservation", args(ctx)))),
1446
+ abortPreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("abortPreservation", args(ctx)))),
1447
+ });
1448
+ }
966
1449
  async commitResumeActivation(attemptId) {
967
1450
  if (!this.resumeAwaitingCommit || !this.resumeResult?.ok)
968
1451
  return { ok: false, error: `resume attempt ${attemptId} has no successful activation to commit` };
@@ -998,83 +1481,12 @@ export class Manager {
998
1481
  data: { attemptId, state: "awaitingFinalize", durableCommitToken: this.resumeDurableCommitToken },
999
1482
  };
1000
1483
  }
1001
- async handleActive(req, tier) {
1002
- const args = req.args ?? {};
1003
- // `req.from.id` is non-forgeable in auth mode: serveControl rejects any request whose payload
1004
- // `from.id` doesn't match the subject sender (endpoint.ts). In open mode there are no creds, so
1005
- // from.id is self-asserted the spawner ledger + this routing are auth-mode guarantees,
1006
- // advisory in open mode (consistent with "open = single-trusted-host"). Thread it to every op
1007
- // so authz (P2c) and the spawner ledger (P4b) can act on it.
1008
- const caller = req.from.id;
1009
- const name = String(args.name ?? "").trim();
1010
- // Op↔tier binding — the real enforcement per the split. The cred gates WHO can reach each
1011
- // subject; this gates WHAT each subject will honor, fail-closed. A privileged op arriving on
1012
- // the self-service subject (publishable by all) must be rejected or the split does nothing.
1013
- if (tier === CONTROL_SELF_SERVICE) {
1014
- // Self-service honors self-ops only: a no-name stop (self-despawn). Durable join/leave/list moved
1015
- // OFF the manager onto the server-side delivery daemon's `ctl.delivery` service (the manager is
1016
- // lifecycle-only). A named stop (belongs on privileged/admin) or anything else is a misroute.
1017
- if (req.op !== "stop")
1018
- return { ok: false, error: `op "${req.op}" not allowed on self-service control subject` };
1019
- if (name)
1020
- return { ok: false, error: "named stop not allowed on self-service subject; send it on the privileged subject" };
1021
- return this.opStopSelf(caller, args);
1022
- }
1023
- const admin = tier === CONTROL_ADMIN;
1024
- // Privileged + admin tiers. A no-name stop is a self-op and belongs on the self-service subject.
1025
- switch (req.op) {
1026
- case "start":
1027
- // Spawn is a privileged-tier op; reaching it via admin is fine (admin ⊇ privileged powers).
1028
- return this.opStart(args, caller);
1029
- case "launch":
1030
- // SECURITY: on a STATIC mesh, manifest launch is operator-only (admin tier). It is
1031
- // higher-power than `start` — it boots an operator-authored, coordinated policy set from a
1032
- // run spec and underpins the ownership ledger — so a merely spawn-capable agent (which CAN
1033
- // publish to the privileged subject) must not reach it. Gate at the handler like `purge`;
1034
- // the subject alone isn't a boundary because `spawn` grants privileged-subject publish and
1035
- // dispatch is by op here. On a USER mesh, a spawn-scoped operator deploys THEIR OWN team on
1036
- // the privileged tier: opLaunch enforces owner-equality (the spec's apply-time stamped
1037
- // owner === the subject-pinned caller's owner) BEFORE any side effect.
1038
- if (!admin && !this.userMode)
1039
- return { ok: false, error: "launch is admin-only; not allowed on the privileged subject" };
1040
- return this.opLaunch(args, caller, admin);
1041
- case "stop": {
1042
- if (!name)
1043
- return { ok: false, error: "self-stop not allowed on privileged subject; send it on the self-service subject" };
1044
- return this.opStop(args, caller, admin);
1045
- }
1046
- case "definePersona":
1047
- return this.opDefinePersona(args, caller, admin);
1048
- case "purge":
1049
- // SECURITY: purge clears space history incl. DMs — admin-only. On the privileged tier any
1050
- // spawn-capable agent could wipe the space, so it must not be honored there.
1051
- if (!admin)
1052
- return { ok: false, error: "purge is admin-only; not allowed on the privileged subject" };
1053
- return this.opPurge(args, caller);
1054
- case "attach":
1055
- return this.opAttach(args, caller, admin);
1056
- case "ps":
1057
- // USER mesh, privileged tier: `ps` lists only the CALLER's own owner-domain (the admin tier
1058
- // OR a fresh ledger `admin` scope sees all) — cross-owner agent metadata (principals,
1059
- // personas, auth health) is operator-grade. Fail-closed: an unparseable caller sees nothing.
1060
- // Static meshes are unchanged.
1061
- return { ok: true, data: this.list(await this.psOwnerFilter(caller, admin)) };
1062
- case "models":
1063
- return this.opModels(args);
1064
- case "status": {
1065
- // Same owner-domain bound as `ps`: a cross-owner target reads as absent, never as metadata.
1066
- const a = this.list(await this.psOwnerFilter(caller, admin)).find((x) => x.name === name);
1067
- return a ? { ok: true, data: a } : { ok: false, error: `no agent "${name}"` };
1068
- }
1069
- default:
1070
- return { ok: false, error: `unknown op: ${req.op}` };
1071
- }
1072
- }
1073
- /** Collapsed despawn/attach authorization (P4b). The caller already reached the privileged or
1074
- * admin tier (cred-gated). On the admin tier any named target is allowed (operator). On the
1075
- * privileged tier a named target is allowed if it's the caller's OWN child (`spawner ==
1076
- * caller`) — and, on a user mesh, if it runs under the CALLER'S OWNER (owner-domain) or the
1077
- * caller's ledger row holds `admin`, read fresh. The policy is the pure
1484
+ /** Collapsed despawn/attach authorization (P4b). The caller already reached the command's ep
1485
+ * row (cred-gated: owner-mode rows via the spawn capability, any-mode rows only in admin
1486
+ * instruments). With admin=true (any-mode) any named target is allowed (operator). Otherwise
1487
+ * a named target is allowed if it's the caller's OWN child (`spawner == caller`) — and, on a
1488
+ * user mesh, if it runs under the CALLER'S OWNER (owner-domain) or the caller's ledger row
1489
+ * holds `admin`, read fresh. The policy is the pure
1078
1490
  * {@link authorizeNamedControl}; this wrapper only binds the manager's state (the mode flag +
1079
1491
  * the provider-backed ledger read — a build with no provider authorizes nothing extra,
1080
1492
  * fail-closed via the policy's catch). Error string when denied, `undefined` when allowed. */
@@ -1174,6 +1586,9 @@ export class Manager {
1174
1586
  * footprint, nor (in `reapChildrenOf`) abort the reap of later siblings. The failure is logged loudly,
1175
1587
  * never swallowed silently. Being the single stop chokepoint, guarding here covers all callers at once. */
1176
1588
  stopHandle(a, graceful) {
1589
+ // The F5 TERMINALIZING latch (Unit B): flipped SYNCHRONOUSLY, before any await anywhere on
1590
+ // this stop path — from here this principal's control ops refuse and no credential renews.
1591
+ a.terminalizing = true;
1177
1592
  try {
1178
1593
  if (graceful && process.platform === "win32" && a.control)
1179
1594
  controlShutdown(a.control);
@@ -1258,7 +1673,9 @@ export class Manager {
1258
1673
  // user-mode spawn reads the callout material from it — the same store the auth-store kinds
1259
1674
  // (callout/issuer/…) were migrated onto — so this is no longer a local-only path.
1260
1675
  const secrets = this.secrets;
1261
- const files = agentSecretFilePaths(this.workspaceRoot, name);
1676
+ // LIFECYCLE-KEYED family (SPEC 13.1 name-disjointness on the FS): this incarnation's files
1677
+ // embed its uid, so no teardown addressed to another incarnation can ever reach them.
1678
+ const files = agentLifecycleSecretFilePaths(this.workspaceRoot, name, opts.lifecycleUid);
1262
1679
  const { actorToken: tokenPath, sentinelCreds: sentinelPath, health: healthPath } = files;
1263
1680
  try {
1264
1681
  // The GRANT first — it is the envelope-rule enforcement point (a delegation must sit within
@@ -1289,10 +1706,10 @@ export class Manager {
1289
1706
  // The store holds the source of truth; the bearer re-exec (`--token-file`) and the launch's
1290
1707
  // sentinel handoff read FILES, so materialize both at the canonical paths (under the local
1291
1708
  // FS composition, a byte-identical rewrite of the keys' own locations).
1292
- await secrets.put(agentActorTokenKey(name), grant.actorToken);
1293
- await secrets.put(agentSentinelCredsKey(name), grant.sentinelCreds);
1294
- await materializeSecretToFile(secrets, agentActorTokenKey(name), tokenPath);
1295
- await materializeSecretToFile(secrets, agentSentinelCredsKey(name), sentinelPath);
1709
+ await secrets.put(agentSecretKeyForFile(tokenPath), grant.actorToken);
1710
+ await secrets.put(agentSecretKeyForFile(sentinelPath), grant.sentinelCreds);
1711
+ await materializeSecretToFile(secrets, agentSecretKeyForFile(tokenPath), tokenPath);
1712
+ await materializeSecretToFile(secrets, agentSecretKeyForFile(sentinelPath), sentinelPath);
1296
1713
  rmSync(healthPath, { force: true }); // a fresh start opens a fresh health window
1297
1714
  const bearerCmd = [
1298
1715
  // The manager's own invocation prefix (node + loader flags + the cotal entry) — the agent
@@ -1311,7 +1728,7 @@ export class Manager {
1311
1728
  "--health-file", healthPath,
1312
1729
  ];
1313
1730
  await execBearerPreflight(bearerCmd);
1314
- return { owner, launch: { owner, actor: name, sentinelCredsPath: sentinelPath, bearerCmd } };
1731
+ return { owner, files, launch: { owner, actor: name, sentinelCredsPath: sentinelPath, bearerCmd } };
1315
1732
  }
1316
1733
  catch (e) {
1317
1734
  // Roll back everything this attempt materialized — a refused spawn must leave no standing
@@ -1319,12 +1736,12 @@ export class Manager {
1319
1736
  // may respawn the moment it reads the refusal, and a detached teardown would race (and
1320
1737
  // delete) that fresh spawn's just-provisioned durables.
1321
1738
  await provider.revokeAgent({ dir, owner, actor: name }).catch(() => { });
1322
- await secrets.delete(agentActorTokenKey(name)).catch(() => { });
1323
- await secrets.delete(agentSentinelCredsKey(name)).catch(() => { });
1739
+ await secrets.delete(agentSecretKeyForFile(tokenPath)).catch(() => { });
1740
+ await secrets.delete(agentSecretKeyForFile(sentinelPath)).catch(() => { });
1324
1741
  rmSync(tokenPath, { force: true });
1325
1742
  rmSync(sentinelPath, { force: true });
1326
1743
  rmSync(healthPath, { force: true });
1327
- await this.deprovision({ id: principalKey(owner, name).key, name, lifecycleUid: opts.lifecycleUid, userOwner: owner }).catch((err) => console.error(`rollback deprovision ${name}: ${err.message}`));
1744
+ await this.deprovision({ id: principalKey(owner, name).key, name, lifecycleUid: opts.lifecycleUid, userOwner: owner, secretPaths: files }).catch((err) => console.error(`rollback deprovision ${name}: ${err.message}`));
1328
1745
  return { error: `agent auth preflight failed for "${name}": ${e.message}` };
1329
1746
  }
1330
1747
  }
@@ -1336,7 +1753,12 @@ export class Manager {
1336
1753
  freeSlot(a, floor, acceptedBeforeFence = false) {
1337
1754
  if (this.agents.get(a.name) !== a)
1338
1755
  return; // already freed (exit raced despawn, etc.)
1756
+ a.terminalizing = true; // F5 latch (Unit B): also covers exit/reap paths that never rode stopHandle
1339
1757
  this.agents.delete(a.name);
1758
+ // P2 item 6 (pin 4): end any live §13.6 attach session bound to THIS incarnation with the honest
1759
+ // `target-despawn` reason. Fires once per agent on every free path (despawn / self-stop / reap /
1760
+ // exit) via the `agents` guard above; a no-op when no plane or no live session for the target.
1761
+ this.sessionPlane?.endForTarget(a.name, a.lifecycleUid, "target-despawn");
1340
1762
  if (floor && Date.now() - a.startedAt < MIN_LIFETIME)
1341
1763
  this.cooling.push(a.startedAt + MIN_LIFETIME);
1342
1764
  // #29 piece 3: on a USER mesh the name is RESERVED PENDING RETIREMENT — despawn started this
@@ -1350,7 +1772,14 @@ export class Manager {
1350
1772
  if (this.userMode) {
1351
1773
  const p = parsePrincipalKey(a.id);
1352
1774
  if (p)
1353
- this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid), lifecycleUid: a.lifecycleUid, owner: p.owner, actor: p.actor, agentId: a.id, userOwner: a.userOwner, startedAt: Date.now() });
1775
+ this.retiring.set(a.name, { opId: retireOpId(a.lifecycleUid), lifecycleUid: a.lifecycleUid, owner: p.owner, actor: p.actor, agentId: a.id, userOwner: a.userOwner, secretPaths: a.secretPaths, startedAt: Date.now() });
1776
+ }
1777
+ else if (this.auth) {
1778
+ // Unit B: a STATIC lifecycle now also holds its name pending its own terminal (the F1
1779
+ // static retirement the detached deprovision below drives) — the alias frees only when the
1780
+ // gate+head terminal completes, exactly the user-mode discipline. The wire principal is the
1781
+ // incarnation-unique nkey (F5-bind); owner is the dev owner.
1782
+ 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() });
1354
1783
  }
1355
1784
  // Auth mode: tear down the departed agent's minted broker footprint + creds file (#159 B2). The
1356
1785
  // process is already gone, so this must never block the slot free or throw into the caller — it runs
@@ -1392,25 +1821,44 @@ export class Manager {
1392
1821
  async driveDeprovision(a) {
1393
1822
  if (!this.auth)
1394
1823
  return; // guaranteed by deprovision; re-checked for the deprovisionBroker narrowing
1824
+ if (!this.userMode && !a.userOwner) {
1825
+ // Unit B: a STATIC lifecycle retires through the F1 terminal barrier — freeze → head
1826
+ // retiring → B1 ledger revoke → footprint cleanup (creds file + broker durables/ACL, INSIDE
1827
+ // the barrier) → gate retired → head retired → alias free. The eviction step is the process
1828
+ // kill the stop path already performed (static's best-effort eviction).
1829
+ return this.driveStaticRetirement(a);
1830
+ }
1395
1831
  // Drop the local creds file FIRST + unconditionally — it is a usable identity on disk, useless for a
1396
1832
  // departed agent, so it must not survive even if the broker teardown below fails or times out. The
1397
1833
  // teardown mints its OWN deprovisioner cred (not this file), so removing it early is independent.
1398
1834
  // Migrated kinds: the store delete is the authoritative removal; the rmSync clears the FS
1399
- // materialization (a byte-identical no-op under the local composition, real once the manager's
1835
+ // materialization (a byte-identical no-op under the local composition, real once the manager
1400
1836
  // `secretStore` is a non-FS store).
1837
+ //
1838
+ // LIFECYCLE-OWNED (SPEC 13.1, the manager-local half): the family deleted here is the RECORDED
1839
+ // one (spawn/adoption), else the lifecycle-keyed derivation for THIS uid — never a name-only
1840
+ // derivation, so a stale/replayed teardown addresses only names a same-alias successor never
1841
+ // uses. It deliberately CANNOT remove a name-keyed family it holds no record of (an operator's
1842
+ // standing `cotal mint` cred, a seeded workstation cred, a pre-split leftover): deleting an
1843
+ // unowned same-name file is the exact successor-clobber this ownership discipline removes.
1401
1844
  const secrets = this.secrets;
1402
- const files = agentSecretFilePaths(this.workspaceRoot, a.name);
1403
- await secrets.delete(agentCredsKey(a.name));
1404
- rmSync(files.creds, { force: true });
1845
+ const files = a.secretPaths ?? agentLifecycleSecretFilePaths(this.workspaceRoot, a.name, a.lifecycleUid);
1846
+ if (files.creds) {
1847
+ await secrets.delete(agentSecretKeyForFile(files.creds));
1848
+ rmSync(files.creds, { force: true });
1849
+ }
1405
1850
  if (a.userOwner) {
1406
1851
  // USER MODE: this teardown IS revocation, not just footprint reduction — the ledger row is
1407
1852
  // the agent's standing mint authority, so delete it (next exchange refused, next connect
1408
1853
  // denied) and shred the secret/sentinel/health files. A copied actor token dies here; a
1409
1854
  // still-LIVE connection ends at its bearer-bound JWT expiry (≤ the agent TTL).
1410
- await secrets.delete(agentActorTokenKey(a.name));
1411
- await secrets.delete(agentSentinelCredsKey(a.name));
1855
+ if (files.actorToken)
1856
+ await secrets.delete(agentSecretKeyForFile(files.actorToken));
1857
+ if (files.sentinelCreds)
1858
+ await secrets.delete(agentSecretKeyForFile(files.sentinelCreds));
1412
1859
  for (const f of [files.actorToken, files.sentinelCreds, files.health])
1413
- rmSync(f, { force: true });
1860
+ if (f)
1861
+ rmSync(f, { force: true });
1414
1862
  // The ledger row IS the agent's STANDING mint authority (a different store from the auth-plane
1415
1863
  // cred ledger the rail retirement covers): while it lives, a copied actor token can still mint a
1416
1864
  // fresh connect credential. So a FAILED revoke must NOT be swallowed into a clean terminal (INT-2):
@@ -1490,7 +1938,15 @@ export class Manager {
1490
1938
  const nc = await connect({ servers: this.servers ?? DEFAULT_SERVER, authenticator: credsAuthenticator(new TextEncoder().encode(creds)), maxReconnectAttempts: 0 });
1491
1939
  try {
1492
1940
  const subject = controlServiceSubject(this.space, CONTROL_AUTH_ADMIN, me.owner, me.actor);
1493
- const m = await nc.request(subject, JSON.stringify({ op: "retireLifecycle", args: { owner: target.owner, actor: target.actor, lifecycleUid: a.lifecycleUid, opId: retireOpId(a.lifecycleUid) } }), { timeout: 20_000, noMux: true, reply: `${subject}.reply.${randomUUID()}` });
1941
+ const m = await nc.request(subject,
1942
+ // P2 item 3 (3b-3): declare THIS manager instance's current serve identity so the rail's
1943
+ // holder check is registration-record-derived (the serve-issuance gate), not a name-derived
1944
+ // "the manager". A superseded predecessor (same instanceId, OLD epoch after a restart) is
1945
+ // then refused at the rail — its declared epoch no longer matches the gate's current one.
1946
+ JSON.stringify({ op: "retireLifecycle", args: {
1947
+ owner: target.owner, actor: target.actor, lifecycleUid: a.lifecycleUid, opId: retireOpId(a.lifecycleUid),
1948
+ serveEndpoint: MANAGER_ENDPOINT, serveInstanceId: this.managerInstanceId, serveEpoch: this.serviceServe?.grant.epoch ?? 0,
1949
+ } }), { timeout: 20_000, noMux: true, reply: `${subject}.reply.${randomUUID()}` });
1494
1950
  const r = m.json();
1495
1951
  if (r.ok) {
1496
1952
  // CAS the hold clear (audit #1 ABA): free the alias ONLY if the current hold is still THIS
@@ -1584,11 +2040,37 @@ export class Manager {
1584
2040
  ? undefined
1585
2041
  : `unsafe name ${JSON.stringify(name)} (allowed: letters, digits, _ -)`;
1586
2042
  }
1587
- /** First free name in the series `base`, `base-2`, `base-3`, … — checked against both live and
1588
- * in-flight (reserved) slots. Lets a colliding spawn auto-number instead of being rejected, so
1589
- * callers never have to invent a unique name. */
2043
+ /** First free name in the series `base`, `base-2`, `base-3`, … — checked against live slots,
2044
+ * in-flight (reserved) slots, names held pending retirement, AND the live mesh roster. The
2045
+ * roster check covers occupants this manager does not manage (a foreground `cotal spawn`, a
2046
+ * connector session, another manager's agent): allocating their name would mint a sibling the
2047
+ * broker/auth then refuses to admit, surfacing as a 30s launch-uncertain black hole instead of
2048
+ * the auto-number the join path gives. Presence is ADVISORY (SPEC §6) — this is an availability
2049
+ * choice at allocation, never an authority check (the broker still enforces): a stale
2050
+ * still-live-looking row only costs a numbered suffix, and a missed freshly-joined occupant is
2051
+ * still refused downstream exactly as before. Offline rows do NOT occupy — a properly retired
2052
+ * name stays reusable. */
2053
+ /** The roster's LIVE occupant names (status !== offline) — occupants this manager may NOT manage
2054
+ * (a foreground `cotal spawn`, a connector session, ANOTHER manager's agent). Allocating over any
2055
+ * of them mints a sibling the broker/auth then refuses to admit, surfacing as the 30s launch-
2056
+ * uncertain black hole. */
2057
+ liveRosterNames() {
2058
+ const live = new Set();
2059
+ for (const p of this.ep.getRoster())
2060
+ if (p.status !== "offline")
2061
+ live.add(p.card.name);
2062
+ return live;
2063
+ }
2064
+ /** THE single name-liveness predicate both the hard-pinned collision refuse (M6, P2 item 2) and
2065
+ * uniqueName's numbering consult, so they can never drift: a name is taken if this manager
2066
+ * reserves/manages/retires it OR a roster-live occupant already holds it. Pass a pre-built
2067
+ * {@link liveRosterNames} set when checking many names in one allocation. */
2068
+ nameInUse(name, live = this.liveRosterNames()) {
2069
+ return this.agents.has(name) || this.reserved.has(name) || this.retiring.has(name) || live.has(name);
2070
+ }
1590
2071
  uniqueName(base) {
1591
- return firstFreeName(base, (n) => this.agents.has(n) || this.reserved.has(n) || this.retiring.has(n));
2072
+ const live = this.liveRosterNames();
2073
+ return firstFreeName(base, (n) => this.nameInUse(n, live));
1592
2074
  }
1593
2075
  /** Spawn a teammate by persona ref (`name` loads `.cotal/agents/<name>.md`; the peer presents
1594
2076
  * under that file's own `name:`), as if a peer asked via the control plane. Used to pre-spawn the
@@ -1611,7 +2093,7 @@ export class Manager {
1611
2093
  return false;
1612
2094
  }
1613
2095
  /** Parse an untyped control-plane `start` request into {@link StartAgentOpts}. */
1614
- opStart(args, caller) {
2096
+ opStart(args, caller, hooks) {
1615
2097
  // `resume`, when present, must be a non-empty session id. An empty/whitespace value is a
1616
2098
  // malformed request, not an implicit "spawn fresh" (no fallbacks). The CLI surfaces reject it,
1617
2099
  // but a raw control message could otherwise slip an empty value through and silently start fresh.
@@ -1658,7 +2140,7 @@ export class Manager {
1658
2140
  allowSubscribe,
1659
2141
  allowPublish,
1660
2142
  shareTools: args.shareTools !== undefined ? String(args.shareTools) : undefined,
1661
- }, caller);
2143
+ }, caller, hooks);
1662
2144
  }
1663
2145
  /** Resolve a connector by agent type. Library composition (installedExtensions off) → a registry
1664
2146
  * hit, exactly as before (the composition root imported what it wants). The published binary gates
@@ -1768,7 +2250,7 @@ export class Manager {
1768
2250
  * (collision-numbered) name + nkey id creds are filed under, plus the manifest `requested` name,
1769
2251
  * `runId`, and resolved `hash`. USER mesh: a privileged-tier launch is owner-equality-authorized
1770
2252
  * (spec owner === caller owner) before any side effect; the admin tier keeps operator behavior. */
1771
- async opLaunch(args, caller, admin) {
2253
+ async opLaunch(args, caller, admin, hooks) {
1772
2254
  const runId = String(args.runId ?? "").trim();
1773
2255
  const name = String(args.name ?? "").trim();
1774
2256
  if (!runId || !name)
@@ -1820,7 +2302,7 @@ export class Manager {
1820
2302
  catch (e) {
1821
2303
  return { ok: false, error: e.message };
1822
2304
  }
1823
- const reply = await this.startAgent(launchAgentToStartOpts(la, configPath, spec.owner, runId), caller);
2305
+ const reply = await this.startAgent(launchAgentToStartOpts(la, configPath, spec.owner, runId), caller, hooks);
1824
2306
  if (reply.ok)
1825
2307
  // `data.name` stays the spawned (numbered) identity — what creds are filed under and the ledger
1826
2308
  // keys on; `requested`/`runId`/`hash` give the CLI the manifest name + drift hash for the ledger.
@@ -1833,18 +2315,18 @@ export class Manager {
1833
2315
  * `spawner` is the authenticated id of the peer that requested the spawn (`req.from.id`),
1834
2316
  * defaulting to the manager's own id for roster/pre-spawn — recorded for the spawner
1835
2317
  * ledger (own-children despawn + reap-on-parent-exit). */
1836
- async startAgent(opts, spawner) {
2318
+ async startAgent(opts, spawner, hooks) {
1837
2319
  const release = this.beginLifecycle();
1838
2320
  if (!release)
1839
2321
  return { ok: false, error: this.maintenanceError() };
1840
2322
  try {
1841
- return await this.startAgentActive(opts, spawner);
2323
+ return await this.startAgentActive(opts, spawner, hooks);
1842
2324
  }
1843
2325
  finally {
1844
2326
  release();
1845
2327
  }
1846
2328
  }
1847
- async startAgentActive(opts, spawner) {
2329
+ async startAgentActive(opts, spawner, hooks) {
1848
2330
  // The spawn argument is a persona REF — a filename in `.cotal/agents` (the unique spawn KEY), or
1849
2331
  // a path via `--config`. It is NOT the mesh identity: the identity comes from inside the file
1850
2332
  // (`name:`), so a persona can be filed descriptively (review-critic.md) yet present under a
@@ -1974,7 +2456,7 @@ export class Manager {
1974
2456
  // outstanding. All the teardown ops are idempotent, and the rail request is single-flighted.
1975
2457
  const held = this.retiring.get(identityName);
1976
2458
  if (held !== undefined) {
1977
- void this.deprovision({ id: held.agentId, name: identityName, lifecycleUid: held.lifecycleUid, userOwner: held.userOwner }).catch(() => { });
2459
+ void this.deprovision({ id: held.agentId, name: identityName, lifecycleUid: held.lifecycleUid, userOwner: held.userOwner, secretPaths: held.secretPaths }).catch(() => { });
1978
2460
  return {
1979
2461
  ok: false,
1980
2462
  error: `the name "${identityName}" is reserved pending retirement: its previous agent's despawn started that lifecycle's teardown (footprint + standing-authority revoke + auth-side retirement), and the name frees only when all of it completes${held.lastError !== undefined ? ` (last attempt: ${held.lastError})` : ""}. NEXT: wait a moment and retry this spawn (retrying re-drives the whole teardown), or pick another name.`,
@@ -1982,7 +2464,34 @@ export class Manager {
1982
2464
  }
1983
2465
  if (variant && !connector.supportsModelVariant)
1984
2466
  return { ok: false, error: `${agent} connector does not support model variants (variant)` };
1985
- const name = this.uniqueName(identityName);
2467
+ // #4 A4 (panel): the roster the allocation consults must reflect the initial presence snapshot,
2468
+ // or a spawn immediately after manager boot races an already-live unmanaged peer and re-opens the
2469
+ // very collision black-hole this closes. Await the snapshot (bounded internally, fail-safe on an
2470
+ // empty mesh) before allocating; the broker/auth remain the authority downstream. Deliberately
2471
+ // unconditional: a half-wired endpoint without the seam must fail loud here, not silently
2472
+ // allocate off a pre-snapshot roster.
2473
+ await this.ep.waitForPresenceSnapshot();
2474
+ // M6 (P2 item 2 spawn-as-action): a HARD-PINNED name — an imperative `--name`/identity override
2475
+ // or a manifest-declared name (opts.resolved) — that collides with a LIVE/provisioning/reserved
2476
+ // incarnation REFUSES loud at accept, BEFORE any reserve/mint/bind (pin 1), never a silent `-2`
2477
+ // suffix (so an address-by-triple caller's pinned name can't be re-pointed). A PERSONA-DERIVED
2478
+ // base name (no pin) keeps uniqueName's collision numbering, so multi-peer `spawn reviewer` twice
2479
+ // still yields reviewer + reviewer-2. The retiring-hold refuse (~2472) is orthogonal and already fired.
2480
+ const hardPinned = opts.identity !== undefined || opts.resolved !== undefined;
2481
+ let name;
2482
+ if (hardPinned) {
2483
+ // The collision check consults THE SAME liveness source uniqueName uses ({@link nameInUse}:
2484
+ // this manager's agents/reserved/retiring PLUS the roster-live set) - a hard-pinned name
2485
+ // colliding with ANY live incarnation (managed, unmanaged foreground/connector, or another
2486
+ // manager's agent) refuses cleanly at accept, rather than minting the collision and black-
2487
+ // holing on the broker/auth refusal (item 3: a pinned name live under another manager MUST refuse).
2488
+ if (this.nameInUse(identityName))
2489
+ return { ok: false, error: `the name "${identityName}" is hard-pinned (${opts.resolved ? "manifest-declared" : "--name/identity override"}) but is already held by a live incarnation (managed here, an unmanaged foreground/connector session, or another manager's agent); a pinned same-name collision refuses at accept - pick another name or despawn the existing one` };
2490
+ name = identityName;
2491
+ }
2492
+ else {
2493
+ name = this.uniqueName(identityName);
2494
+ }
1986
2495
  this.reserved.add(name);
1987
2496
  // Transcript mirroring (opt-in: `--transcript` / COTAL_TRANSCRIPT_DEFAULT=1) → grant the agent pub
1988
2497
  // on its OWN transcript channel; auth-mode publish is default-deny, so without the grant the mirror's
@@ -1999,6 +2508,18 @@ export class Manager {
1999
2508
  }
2000
2509
  allowPublish = [...(allowPublish ?? []), connector.transcriptChannel(name)];
2001
2510
  }
2511
+ // F2 (Unit B): a STATIC managed spawn REFUSES endpoint capabilities, fail-closed IN CODE (not
2512
+ // a doc note): the static terminal has no obligation-drain/frontier steps yet, so an accepted-
2513
+ // but-uncompleted endpoint obligation could execute AFTER its uid is declared retired. The
2514
+ // refusal sits at spawn-accept, before any provisioning, over the same records a persona or
2515
+ // manifest self-claim would ride in on — capabilities cannot slip past it into the grant path.
2516
+ if (this.auth && !this.userMode) {
2517
+ const claims = [opts, (opts.resolved ?? {})];
2518
+ if (claims.some((c) => c.endpointCapabilities !== undefined)) {
2519
+ this.reserved.delete(name);
2520
+ return { ok: false, error: "a static managed spawn refuses endpointCapabilities (Unit B F2): the static lifecycle terminal carries no obligation-drain/frontier steps, so endpoint-rail grants are not containable in static mode" };
2521
+ }
2522
+ }
2002
2523
  // Set once the agent's creds + durables are minted; cleared the moment a live slot takes ownership
2003
2524
  // (`agents.set`, after which freeSlot deprovisions on exit). If it survives to `finally`, the spawn
2004
2525
  // threw AFTER minting (buildLaunch / runtime.spawn) — tear the orphan down so no footprint leaks (#159 B).
@@ -2016,6 +2537,19 @@ export class Manager {
2016
2537
  // broker resource (dm_/dlv_/chathist_ durables, ACL row, memberships) and the teardown
2017
2538
  // credential carry it, so a same-name successor's footprint is name-disjoint by construction.
2018
2539
  const lifecycleUid = mintLifecycleUid();
2540
+ // ACCEPT SEAM (P2 item 2 spawn-as-action): the incarnation identity is minted and NOTHING has
2541
+ // been provisioned yet — the action serve path binds the goal + replies the acceptance HERE. A
2542
+ // throw (bind conflict / duplicate goalId) aborts the spawn before provisioning: the catch below
2543
+ // returns the failure and the finally releases the reserve, so a refused accept leaves zero
2544
+ // footprint (pin 1). Blocking callers (roster boot) pass no hooks and this is a no-op.
2545
+ // The ALLOCATED agent's addressing triple (the acceptance floor names what was actually
2546
+ // allocated, never the requested-but-unallocated name). Static/open key on DEV_OWNER + the
2547
+ // freshly-minted nkey; user mode keys on the derived owner (opts.owner, else a u_-owner spawner)
2548
+ // + the alias — derived HERE where the mode and owner source are in scope.
2549
+ const agentTriple = this.userMode
2550
+ ? { owner: opts.owner ?? (spawner && parsePrincipalKey(spawner)?.owner.startsWith("u_") ? parsePrincipalKey(spawner).owner : DEV_OWNER), actor: name, uid: lifecycleUid }
2551
+ : { owner: DEV_OWNER, actor: identity.id, uid: lifecycleUid };
2552
+ await hooks?.onAccepted?.({ name, identity, lifecycleUid, agentTriple });
2019
2553
  // In auth mode, mint the agent's creds from the space signing key and write them where the
2020
2554
  // spawned session reads them (COTAL_CREDS path). Open mesh → no creds. Scope = the resolved
2021
2555
  // subscribe/allowSubscribe (read) + allowPublish (post, default-deny).
@@ -2040,13 +2574,25 @@ export class Manager {
2040
2574
  }
2041
2575
  userLaunch = prep.launch;
2042
2576
  userOwner = prep.owner;
2043
- provisioned = { id: principalKey(prep.owner, name).key, name, lifecycleUid, userOwner: prep.owner };
2577
+ provisioned = { id: principalKey(prep.owner, name).key, name, lifecycleUid, userOwner: prep.owner, secretPaths: prep.files };
2044
2578
  }
2045
2579
  else if (this.auth) {
2580
+ // Unit B (§13.1): reserve + activate this incarnation's DURABLE identity BEFORE any
2581
+ // broker footprint — the F3 outer spawn intent first (slot row, phase `provisioning`),
2582
+ // then the SHARED core activation saga (reserve uid -> gate frozen -> head CAS -> reopen
2583
+ // LAST) over the key-pinned executor. The wire AUTHORITY principal is the incarnation-
2584
+ // unique nkey (F5-bind); the alias is protected by the name-keyed slot + freeSlot hold.
2585
+ 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 }));
2586
+ // From here the DURABLE registration exists: arm the rollback BEFORE minting, so a throw
2587
+ // between activation and provisioning still drives the exact-op static terminal (the
2588
+ // finally's deprovision tolerates absent files; the broker teardown is idempotent).
2589
+ provisioned = { id: identity.id, name, lifecycleUid };
2046
2590
  // Pre-create the agent's bind-only chat (+ DM + role TASK) durables and mint its scoped creds
2047
2591
  // — the shared onboarding step (provisionAgent). It runs on a short-lived PROVISIONER connection
2048
2592
  // (NOT the supervisor's long-lived endpoint), so the DM/DLV consumer-create surface exists only
2049
2593
  // for the provisioning window, never as a standing grant on the always-on daemon (residual 2).
2594
+ // F5(b): the credential is BOUNDED (`expiresAt`) — the manager push-renews it ahead of expiry.
2595
+ const exp = Math.floor(Date.now() / 1000) + MANAGED_STATIC_TTL_SEC;
2050
2596
  const creds = await this.withProvisioner((prov) => provisionAgent(prov, this.auth, identity, {
2051
2597
  subscribe,
2052
2598
  allowSubscribe,
@@ -2054,15 +2600,26 @@ export class Manager {
2054
2600
  role,
2055
2601
  capabilities,
2056
2602
  lifecycleUid,
2603
+ expiresAt: exp,
2057
2604
  }));
2605
+ // Ledger BEFORE materialization (§13.1): record the credentialId on the slot, append the
2606
+ // `cred.<uid>.<credId>` row, and only then write the credential where anything can read
2607
+ // it — a credential is never materialized before its ledger row exists.
2608
+ const credentialId = rawDigest(creds).replace("sha256:", "sha256-");
2609
+ await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: identity.id, lifecycleUid, alias: name }, async (t) => {
2610
+ await recordSlotCredential(t, DEV_OWNER, name, lifecycleUid, credentialId);
2611
+ await appendStaticCredentialRow(t, { lifecycleUid, credentialId, holderPrincipal: principalKey(DEV_OWNER, identity.id).key, exp });
2612
+ });
2058
2613
  // Store first (the source of truth), then materialize: `buildLaunch` hands the CHILD this
2059
2614
  // file path, so the cred must exist as a file regardless of the store behind the seam. The
2060
2615
  // manager's ONE store (injected for hosted, workstation FS locally).
2061
2616
  const secrets = this.secrets;
2062
- credsPath = agentSecretFilePaths(this.workspaceRoot, name).creds;
2063
- await secrets.put(agentCredsKey(name), creds);
2064
- await materializeSecretToFile(secrets, agentCredsKey(name), credsPath);
2065
- provisioned = { id: identity.id, name, lifecycleUid }; // footprint now exists — the finally rolls it back if the spawn throws
2617
+ // LIFECYCLE-KEYED (SPEC 13.1 on the FS): the incarnation's cred file embeds its uid, so a
2618
+ // replayed/stale teardown can never address a same-name successor's credential.
2619
+ credsPath = agentLifecycleSecretFilePaths(this.workspaceRoot, name, lifecycleUid).creds;
2620
+ await secrets.put(agentSecretKeyForFile(credsPath), creds);
2621
+ await materializeSecretToFile(secrets, agentSecretKeyForFile(credsPath), credsPath);
2622
+ provisioned = { id: identity.id, name, lifecycleUid, secretPaths: { creds: credsPath } }; // footprint now exists — the finally rolls it back if the spawn throws
2066
2623
  }
2067
2624
  // Personal MCP servers the operator opted to share with manager-spawned agents of this type
2068
2625
  // (cotal config; default none → isolated, the memory-safe default this guards), narrowed by
@@ -2116,12 +2673,16 @@ export class Manager {
2116
2673
  workspaceRoot: this.workspaceRoot,
2117
2674
  });
2118
2675
  const handle = this.runtime.spawn(name, spec, cwd);
2676
+ hooks?.onLaunched?.(); // P2 item 2: the "launched" progress edge (process spawned, pre-presence)
2119
2677
  const managed = {
2120
2678
  name,
2121
2679
  role,
2122
2680
  agent,
2123
2681
  id: userLaunch ? principalKey(userLaunch.owner, name).key : identity.id,
2124
2682
  lifecycleUid,
2683
+ // The lifecycle-keyed family this spawn just materialized (absent on an open mesh) — the
2684
+ // recorded truth teardown/preservation/health consume, never re-derived by name.
2685
+ secretPaths: provisioned?.secretPaths,
2125
2686
  ...(userLaunch ? { userOwner } : { seed: identity.seed }),
2126
2687
  spawner: spawner ?? this.ep.ref().id,
2127
2688
  authorityParent: userLaunch && spawner && parsePrincipalKey(spawner) ? spawner : undefined,
@@ -2157,6 +2718,18 @@ export class Manager {
2157
2718
  : undefined,
2158
2719
  },
2159
2720
  };
2721
+ // Unit B: the DURABLE slot takes the `active` phase before the in-memory row takes the
2722
+ // name — a crash between the two leaves an active-but-unadopted slot the boot sweep
2723
+ // terminalizes (never an untracked orphan). Static auth only; a failed CAS fails the spawn
2724
+ // (the finally's rollback then drives the exact-op terminal).
2725
+ if (this.auth && !this.userMode) {
2726
+ await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: managed.id, lifecycleUid, alias: name }, async (t) => {
2727
+ const slot = await readStaticSlot(t, DEV_OWNER, name);
2728
+ if (slot === undefined || slot.row.lifecycleUid !== lifecycleUid || slot.row.phase !== "provisioning")
2729
+ 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`);
2730
+ await casStaticSlot(t, { ...slot.row, phase: "active" }, slot.revision);
2731
+ });
2732
+ }
2160
2733
  this.agents.set(name, managed);
2161
2734
  // The live slot now owns teardown — freeSlot deprovisions this identity on exit — so the
2162
2735
  // orphan-rollback in `finally` no longer applies to it.
@@ -2166,17 +2739,36 @@ export class Manager {
2166
2739
  // neither in time → uncertain. `✓ started` therefore means "it joined", never just "a process
2167
2740
  // launched".
2168
2741
  const readiness = await this.awaitReadiness(managed);
2169
- if (!readiness.ok && !readiness.uncertain)
2170
- return { ok: false, error: readiness.detail }; // failed already reaped
2742
+ // Deliberately stopped mid-launch: reaped by onExit, and the despawn/stop path owns the
2743
+ // goal terminal. Return BEFORE the failed/uncertain arms so this emits no competing
2744
+ // outcome and does not re-arm an exit watcher on an agent already gone.
2745
+ if (!readiness.ok && readiness.deliberate) {
2746
+ hooks?.onTerminalDeferred?.();
2747
+ return { ok: false, error: readiness.detail };
2748
+ }
2749
+ if (!readiness.ok && !readiness.uncertain) {
2750
+ await hooks?.onOutcome?.({ kind: "failed", data: { error: readiness.detail } });
2751
+ return { ok: false, error: readiness.detail };
2752
+ } // failed → already reaped
2171
2753
  // Started OR uncertain: the agent stays managed, so wire the ongoing exit reaper (it reaps a later
2172
2754
  // death — including one that follows an `uncertain` verdict, which deliberately does NOT deprovision).
2173
2755
  this.watchExit(managed);
2174
- if (!readiness.ok)
2175
- return { ok: false, error: readiness.detail }; // uncertain — non-success, but kept
2756
+ if (!readiness.ok) {
2757
+ await hooks?.onOutcome?.({ kind: "uncertain" });
2758
+ return { ok: false, error: readiness.detail };
2759
+ } // uncertain — non-success, but kept
2176
2760
  // Reply with the id the slot actually carries (user-mode: the owner.actor principal —
2177
2761
  // presence, ps, and the manifest ownership ledger all key on it; the throwaway static nkey
2178
2762
  // would never match and down -f would treat the agent as foreign).
2179
- return { ok: true, data: { name, role, agent, id: managed.id, mode: handle.kind } };
2763
+ // `lifecycleUid` rides the reply so callers that record this spawn (the manifest ledger) can
2764
+ // later address the incarnation's lifecycle-keyed artifacts without re-deriving by name.
2765
+ // OMIT an absent role: the goal terminal commits this data through the strict
2766
+ // canonicalJson (undefined never coerces to null, SPEC 13.6), so a role-less spawn would
2767
+ // otherwise fail its succeeded terminal. The CLI/connector already render an absent role as
2768
+ // "no role", so dropping the key preserves the reply (P2 item 2, surfaced by readiness:live).
2769
+ const okData = { name, agent, id: managed.id, mode: handle.kind, lifecycleUid, ...(role !== undefined ? { role } : {}) };
2770
+ await hooks?.onOutcome?.({ kind: "succeeded", data: okData });
2771
+ return { ok: true, data: okData };
2180
2772
  }
2181
2773
  catch (e) {
2182
2774
  // Failure after reserve (provision / launch threw): the slot was never live, so no cold-start
@@ -2287,9 +2879,16 @@ export class Manager {
2287
2879
  if (entry.identity.mode === "static") {
2288
2880
  if (!this.auth || this.userMode)
2289
2881
  throw new Error(`retained agent ${entry.name} is static-auth but the current manager is not`);
2290
- const expected = resolve(agentSecretFilePaths(this.workspaceRoot, entry.name).creds);
2291
- if (resolve(entry.identity.credential.path) !== expected)
2292
- throw new Error(`retained credential reference for ${entry.name} is not the manager-owned path ${expected}`);
2882
+ // CLOSED candidate set, not an open path: the lifecycle-keyed derivation (this generation's
2883
+ // layout) or the name-keyed one (a pre-split inventory being carried across the upgrade).
2884
+ // Anything else is a foreign path and refused exactly as before.
2885
+ const candidates = [
2886
+ resolve(agentLifecycleSecretFilePaths(this.workspaceRoot, entry.name, entry.identity.lifecycleUid).creds),
2887
+ resolve(agentSecretFilePaths(this.workspaceRoot, entry.name).creds),
2888
+ ];
2889
+ const expected = resolve(entry.identity.credential.path);
2890
+ if (!candidates.includes(expected))
2891
+ throw new Error(`retained credential reference for ${entry.name} is not a manager-owned path (expected ${candidates.join(" or ")})`);
2293
2892
  let credentialText;
2294
2893
  try {
2295
2894
  // The lstat guards the FS MATERIALIZATION the child will read at launch; the identity check
@@ -2298,7 +2897,7 @@ export class Manager {
2298
2897
  const st = lstatSync(expected);
2299
2898
  if (!st.isFile() || st.isSymbolicLink())
2300
2899
  throw new Error("not a regular non-symlink file");
2301
- const stored = await this.secrets.get(agentCredsKey(entry.name));
2900
+ const stored = await this.secrets.get(agentSecretKeyForFile(expected));
2302
2901
  if (stored === undefined)
2303
2902
  throw new Error("the credential is not in the secret store");
2304
2903
  credentialText = stored;
@@ -2318,16 +2917,26 @@ export class Manager {
2318
2917
  throw new Error(`retained agent ${entry.name} is user-auth but the current manager is not`);
2319
2918
  try {
2320
2919
  const provider = resolveAuthProvider();
2321
- // Mirror the static branch's expected-path equality: the store reads below are keyed by
2322
- // NAME, so a retained record aimed at a foreign path would otherwise pass its digest checks
2323
- // there while a different secret gets validated here. Canonical paths only.
2324
- const files = agentSecretFilePaths(this.workspaceRoot, entry.name);
2325
- if (resolve(entry.identity.actorToken.path) !== resolve(files.actorToken) ||
2326
- resolve(entry.identity.sentinelCredential.path) !== resolve(files.sentinelCreds))
2327
- throw new Error(`retained identity references are not the manager-owned paths under ${agentCredsDir(this.workspaceRoot)}`);
2920
+ // Mirror the static branch's expected-path discipline, but pin the WHOLE secret FAMILY as ONE
2921
+ // unit: all three of {actorToken, sentinelCreds, health} must equal the lifecycle-keyed triple
2922
+ // (this generation) OR the name-keyed triple (a pre-split inventory carried across the upgrade).
2923
+ // A per-file OR-pin let a corrupt inventory MIX families (lifecycle token + legacy sentinel)
2924
+ // and, worse, left `health` UNPINNED entirely — an arbitrary recorded health path flowed into
2925
+ // the bearer argv and was `rmSync`'d at terminal teardown (inventory-as-delete-gadget). Pinning
2926
+ // the atomic family closes both: `health` is pinned by PATH EQUALITY (never by file existence,
2927
+ // so a transiently-absent health file still validates), and the store reads below key off the
2928
+ // RECORDED path, so a foreign path can neither pass the pin nor address a different row.
2929
+ const lifecycleFiles = agentLifecycleSecretFilePaths(this.workspaceRoot, entry.name, entry.identity.lifecycleUid);
2930
+ const legacyFiles = agentSecretFilePaths(this.workspaceRoot, entry.name);
2931
+ const recordedToken = resolve(entry.identity.actorToken.path);
2932
+ const recordedSentinel = resolve(entry.identity.sentinelCredential.path);
2933
+ const recordedHealth = resolve(entry.identity.health.path);
2934
+ const matchesFamily = (f) => recordedToken === resolve(f.actorToken) && recordedSentinel === resolve(f.sentinelCreds) && recordedHealth === resolve(f.health);
2935
+ if (!matchesFamily(lifecycleFiles) && !matchesFamily(legacyFiles))
2936
+ throw new Error(`retained identity references for ${entry.name} are not one manager-owned secret family: all of actor-token, sentinel, and health must be the lifecycle-<uid> triple or the legacy name-keyed triple under ${agentCredsDir(this.workspaceRoot)} (no mixed families, no foreign health path)`);
2328
2937
  const secrets = this.secrets;
2329
- const actorToken = await secrets.get(agentActorTokenKey(entry.name));
2330
- const sentinelCreds = await secrets.get(agentSentinelCredsKey(entry.name));
2938
+ const actorToken = await secrets.get(agentSecretKeyForFile(recordedToken));
2939
+ const sentinelCreds = await secrets.get(agentSecretKeyForFile(recordedSentinel));
2331
2940
  if (actorToken === undefined || sentinelCreds === undefined)
2332
2941
  throw new Error("the retained actor token / sentinel credential is not in the secret store");
2333
2942
  const adopted = await provider.validateRetainedAgent({
@@ -2414,7 +3023,14 @@ export class Manager {
2414
3023
  }
2415
3024
  let connector;
2416
3025
  try {
2417
- connector = registry.resolve("connector", entry.launch.connector);
3026
+ // The SAME resolver the spawn path uses. A bare `registry.resolve` here made preserve→resume
3027
+ // fail for EVERY retained agent on the published binary: the resuming manager is a fresh
3028
+ // process whose registry is empty (its supervise child runs with the connector seed
3029
+ // skipped), so nothing is registered until something materializes it from the ext manifest.
3030
+ // That is precisely what `resolveConnector` does and what every spawn path already calls.
3031
+ // Resolving bare meant a preserved mesh could never come back — first-party connectors
3032
+ // included, since the asymmetry is about materialization, not about which connector it is.
3033
+ connector = await this.resolveConnector(entry.launch.connector);
2418
3034
  }
2419
3035
  catch (e) {
2420
3036
  return { ok: false, error: e.message };
@@ -2505,14 +3121,33 @@ export class Manager {
2505
3121
  if (!batchReserved)
2506
3122
  this.reserved.add(entry.name);
2507
3123
  try {
3124
+ // Unit B F5(b): recover the STATIC identity's nkey seed from the adopted credential (the
3125
+ // creds file embeds it) so the manager stays this incarnation's RENEWAL OWNER across a
3126
+ // preserve/resume — without it the adopted cred would die loud at its TTL with no remint.
3127
+ let adoptedSeed;
3128
+ if (entry.identity.mode === "static") {
3129
+ const stored = await this.secrets.get(agentSecretKeyForFile(resolve(entry.identity.credential.path)));
3130
+ adoptedSeed = stored === undefined ? undefined : /-----BEGIN USER NKEY SEED-----\s*([A-Z0-9]+)\s*-----END USER NKEY SEED-----/.exec(stored)?.[1];
3131
+ if (adoptedSeed === undefined)
3132
+ console.error(`! resume ${entry.name}: the adopted credential carries no readable nkey seed - the manager cannot renew it (it dies loud at its exp)`);
3133
+ }
2508
3134
  const handle = this.runtime.spawn(entry.name, prepared.spec, entry.launch.cwd);
2509
3135
  const managed = {
2510
3136
  name: entry.name,
2511
3137
  role: entry.role,
2512
3138
  agent: entry.launch.connector,
2513
3139
  id: entry.identity.mode === "user" ? principalKey(entry.identity.owner, entry.identity.actor).key : entry.identity.id,
3140
+ seed: adoptedSeed,
2514
3141
  // Recover the ORIGINAL incarnation uid the durables are keyed by (never a fresh mint on resume).
2515
3142
  lifecycleUid: entry.identity.lifecycleUid,
3143
+ // Adopt the INVENTORY's recorded family (possibly a pre-split name-keyed layout) — the
3144
+ // validated paths above, so this incarnation's later teardown addresses exactly what its
3145
+ // spawn materialized, never a re-derivation.
3146
+ secretPaths: entry.identity.mode === "user"
3147
+ ? { actorToken: entry.identity.actorToken.path, sentinelCreds: entry.identity.sentinelCredential.path, health: entry.identity.health.path }
3148
+ : entry.identity.mode === "static"
3149
+ ? { creds: entry.identity.credential.path }
3150
+ : undefined,
2516
3151
  userOwner: entry.identity.mode === "user" ? entry.identity.owner : undefined,
2517
3152
  spawner: entry.spawner,
2518
3153
  authorityParent: entry.authorityParent,
@@ -2645,9 +3280,46 @@ export class Manager {
2645
3280
  if (done || !s)
2646
3281
  return;
2647
3282
  clearTimeout(timer);
3283
+ // ┌─ DO NOT MOVE THIS READ. Its POSITION is the fix; its value is not. ──────────────────┐
3284
+ // MOVING IT BELOW `onAgentExit` MAKES READINESS STOP REPORTING GENUINE LAUNCH FAILURES.
3285
+ // That is what breaks — not a style regression, a silent loss of every `failed` terminal
3286
+ // for an agent that really did die on launch. `onAgentExit` reaches `freeSlot`, which sets
3287
+ // this SAME latch on its way through (see its "also covers exit/reap paths" comment), so a
3288
+ // read taken after that call is true for EVERY exit, deliberate or not.
3289
+ //
3290
+ // Read HERE — first statement, before the await and before `onAgentExit` — a set latch can
3291
+ // only have been set by someone else, and the only other setter on this path is
3292
+ // `stopHandle`, which latches SYNCHRONOUSLY and then kills with no suspension between. So a
3293
+ // despawn-caused exit is guaranteed observed with the latch UP and a natural exit with it
3294
+ // DOWN: the distinction holds by program order, never by winning a race.
3295
+ //
3296
+ // The same latch, read one function call apart, answers two different questions.
3297
+ //
3298
+ // TO RE-VERIFY (this is the mutation that proves it, and it is the exact regression a tidy
3299
+ // refactor produces): move this capture below `onAgentExit` and run
3300
+ // `pnpm smoke:manager-spawn-action`. M3 `process exit -> failed` must FAIL. Note that M4 —
3301
+ // the case this fix exists for — still PASSES under that mutation, so the suite this fix
3302
+ // was written against cannot catch its own regression. M3 catches it only because it
3303
+ // happens to share a file.
3304
+ // └──────────────────────────────────────────────────────────────────────────────────────┘
3305
+ const deliberate = a.terminalizing === true;
2648
3306
  void (async () => {
2649
3307
  const tail = this.tail(await s.backlog());
2650
3308
  this.onAgentExit(a);
3309
+ // A DELIBERATE STOP IS NOT A LAUNCH FAILURE. The despawn path owns this goal's terminal
3310
+ // and commits `cancel`; reporting `failed` here races it and, when it wins, tells the
3311
+ // caller the agent died on launch when in fact an operator cancelled it. The process
3312
+ // teardown above still runs — only the goal's OUTCOME is left to the path that caused it.
3313
+ // A deliberate stop still has to SETTLE this promise. `clearTimeout` above already
3314
+ // removed the only other resolver, so returning here leaves it pending forever and the
3315
+ // spawn's lifecycle ticket is never released — which permanently wedges every drain
3316
+ // (preparePreservation, and through it a preserving `down`). Settle it as its own
3317
+ // variant: not `failed` and not `uncertain`, so the caller emits NO terminal and the
3318
+ // despawn path keeps sole ownership of this goal's `cancel`.
3319
+ if (deliberate) {
3320
+ finish({ ok: false, deliberate: true, detail: `${a.name} was stopped before it reported ready` });
3321
+ return;
3322
+ }
2651
3323
  finish({ ok: false, detail: `${a.name} exited on launch${tail ? ` - last output: ${tail}` : ""}` });
2652
3324
  })();
2653
3325
  };
@@ -2712,19 +3384,1221 @@ export class Manager {
2712
3384
  const a = this.agents.get(name);
2713
3385
  if (!a)
2714
3386
  return { ok: false, error: `no agent "${name}"` };
3387
+ return this.despawnCore(a, caller, admin, args.graceful !== false);
3388
+ }
3389
+ /** The ONE named-terminal core both doors share (P2 item 1, checklist 8): the ctl named `stop`
3390
+ * and the v0.4 targeted `despawn` are the same terminal — authorize by the shared policy
3391
+ * ({@link authorizeNamed}: own-child / owner-domain on privileged, any on admin), stop, track.
3392
+ * The ep door runs the SAME two pieces separately so a policy denial surfaces as the §13.3
3393
+ * `permission-denied` (never a generic failure). */
3394
+ async despawnCore(a, caller, admin, graceful) {
2715
3395
  const denied = await this.authorizeNamed(a, caller, admin);
2716
3396
  if (denied)
2717
3397
  return { ok: false, error: denied };
2718
- const graceful = args.graceful !== false;
3398
+ return this.despawnAuthorized(a, graceful, !admin);
3399
+ }
3400
+ /** The post-authorization terminal effect (both doors). `trackNonAdmin` mirrors the ctl door's
3401
+ * `trackStoppedHandle(a, !admin)` disposition. */
3402
+ despawnAuthorized(a, graceful, trackNonAdmin) {
2719
3403
  this.stopHandle(a, graceful);
2720
- this.trackStoppedHandle(a, !admin);
2721
- return { ok: true, data: { name, stopped: true, graceful } };
3404
+ this.trackStoppedHandle(a, trackNonAdmin);
3405
+ void this.cancelAgentGoal(a.name, graceful ? "graceful" : "terminate"); // M4: cancel a live spawn goal
3406
+ return { ok: true, data: { name: a.name, stopped: true, graceful } };
3407
+ }
3408
+ /** Resolve a v0.4 TARGET triple (owner, actor, lifecycleUid — broker-validated subject/body
3409
+ * agreement, currency re-checked by the serve boundary's resolver) to the live managed agent it
3410
+ * names. Static agents key `(DEV_OWNER, nkey)`; user-mode agents store the principal dot-form
3411
+ * in `id`. A uid mismatch is a superseded incarnation — never resolved to its successor. */
3412
+ findManagedByTarget(t) {
3413
+ for (const a of this.agents.values()) {
3414
+ const matches = a.userOwner ? a.id === principalKey(t.owner, t.actor).key : t.owner === DEV_OWNER && a.id === t.actor;
3415
+ if (matches && a.lifecycleUid === t.lifecycleUid)
3416
+ return a;
3417
+ }
3418
+ return undefined;
2722
3419
  }
2723
3420
  /** Open a short-lived PROVISIONER connection, run the onboarding ops on it, and drain it (closure (ii),
2724
3421
  * residual 2). The DM/DLV consumer-create surface — the irreducible onboarding power — lives only for
2725
3422
  * this window, never as a standing grant on the long-lived supervisor. A provision-only endpoint
2726
3423
  * (no presence/consume/channel-watch) connected with memory-only `provisioner` creds; it sets its own
2727
3424
  * `inboxPrefix` so JS-API replies land on the `_INBOX_<id>.>` the provisioner cred subscribes. */
3425
+ /** Run one static §13.1 lifecycle OPERATION over an ephemeral, key-pinned `lifecycle-executor`
3426
+ * connection (Unit B): the credential's grants name exactly ONE incarnation's head/uid/gate/
3427
+ * cred-family/slot keys, so the write authority exists only for this operation's window and
3428
+ * can move nothing else. The transport is the direct-KV binding the shared core saga drives. */
3429
+ async withLifecycleExecutor(pin, fn) {
3430
+ if (!this.auth)
3431
+ throw new Error("withLifecycleExecutor: no space auth (an open mesh has no lifecycle registry)");
3432
+ const identity = newIdentity();
3433
+ const creds = await mintCreds(this.auth, identity, "lifecycle-executor", {
3434
+ lifecycleExecutor: { owner: pin.owner, actor: pin.actor, lifecycleUid: pin.lifecycleUid, alias: pin.alias },
3435
+ });
3436
+ const nc = await connect({ servers: this.servers ?? DEFAULT_SERVER, ...standaloneConnectOpts({ creds, /* not yet wired to a recorded transport */ tls: false }), maxReconnectAttempts: 0 });
3437
+ try {
3438
+ const kvm = new Kvm(nc);
3439
+ const recordsKv = await kvm.open(recordsBucket(this.space));
3440
+ const authKv = await kvm.open(epAuthBucket(this.space));
3441
+ return await fn(staticLifecycleTransport(recordsKv, authKv));
3442
+ }
3443
+ finally {
3444
+ await nc.drain().catch(() => nc.close());
3445
+ }
3446
+ }
3447
+ /** Run one §13.1 ENDPOINT-SERVE credential operation (P2 item 1, 1a-serve) over an ephemeral,
3448
+ * key-pinned `endpoint-serve-executor` connection: the credential's grants name exactly the
3449
+ * manager instance's `epgate`/`epcred` keys plus its registration's two records keys, so the
3450
+ * gate CAS, the mint fence, and the spec/governance writes ride a one-shot scoped authority —
3451
+ * NEVER the manager's standing seed/supervisor connection (the panel's "no seed shortcut"). */
3452
+ async withEndpointServeExecutor(fn) {
3453
+ if (!this.auth)
3454
+ throw new Error("withEndpointServeExecutor: no space auth (an open mesh has no service registry)");
3455
+ const identity = newIdentity();
3456
+ const creds = await mintCreds(this.auth, identity, "endpoint-serve-executor", {
3457
+ endpointServeExecutor: { endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId },
3458
+ });
3459
+ const nc = await connect({ servers: this.servers ?? DEFAULT_SERVER, ...standaloneConnectOpts({ creds, /* not yet wired to a recorded transport */ tls: false }), maxReconnectAttempts: 0 });
3460
+ try {
3461
+ const kvm = new Kvm(nc);
3462
+ return await fn({ recordsKv: await kvm.open(recordsBucket(this.space)), authKv: await kvm.open(epAuthBucket(this.space)), nc });
3463
+ }
3464
+ finally {
3465
+ await nc.drain().catch(() => nc.close());
3466
+ }
3467
+ }
3468
+ /** 1d open-mesh counterpart of {@link withEndpointServeExecutor}: an OPEN mesh has no
3469
+ * credential system, so there is no scoped executor to mint - the same §13.1 gate/records
3470
+ * writes ride a bare one-shot connection (the broker enforces nothing on an open mesh; the
3471
+ * ceremony still produces the real gate, epoch, and registration the serve rails run on). */
3472
+ async withOpenServeConnection(fn) {
3473
+ if (this.auth)
3474
+ throw new Error("withOpenServeConnection: an auth mesh must use the scoped endpoint-serve executor");
3475
+ const nc = await connect({ servers: this.servers ?? DEFAULT_SERVER, maxReconnectAttempts: 0 });
3476
+ try {
3477
+ const kvm = new Kvm(nc);
3478
+ // An open mesh may be a RAW broker (no `cotal up` provisioning ran), and `Kvm.open` binds
3479
+ // lazily without checking the stream exists — create-or-verify the §13.12 authority stores
3480
+ // first (the same mode-neutral treatment {@link registerManagerService} gives the contract
3481
+ // store), or the first gate write dies "stream not found".
3482
+ await ensureAuthorityStores(await jetstreamManager(nc), kvm, this.space);
3483
+ return await fn({ recordsKv: await kvm.open(recordsBucket(this.space)), authKv: await kvm.open(epAuthBucket(this.space)), nc });
3484
+ }
3485
+ finally {
3486
+ await nc.drain().catch(() => nc.close());
3487
+ }
3488
+ }
3489
+ /** The served manager-level health summary (1a's one read-only command). */
3490
+ managerStatusData() {
3491
+ return {
3492
+ instanceId: this.managerInstanceId,
3493
+ runtime: this.runtime.kind,
3494
+ agentCount: this.agents.size,
3495
+ uptimeMs: Date.now() - this.startedAtMs,
3496
+ };
3497
+ }
3498
+ /** P2 item 1: register the manager as an ordinary v0.4 `service` endpoint and serve its typed
3499
+ * command surface on the ep rails - since 1d the manager's ONLY control door. On an AUTH mesh
3500
+ * the whole credential path is the SAME one an ordinary endpoint traverses (the enforcement
3501
+ * test that keeps "ordinary" honest): provision the §13.1 issuance gate, drive the
3502
+ * registration BARRIER's gate CAS, then release the serve credential only on the mint FENCE's
3503
+ * revision-pinned CAS win — all over the scoped one-shot executor ({@link
3504
+ * withEndpointServeExecutor}), never a seed-signed shortcut. Holding the signing seed only
3505
+ * AUTHORIZES the reserved single-label name (`manager`, operator name authority, DEV_OWNER).
3506
+ * On an OPEN mesh the same gate/registration/serve-grant ceremony runs over bare one-shot
3507
+ * connections and NO credential is ever minted: there is no credential system to issue from,
3508
+ * so the gate legitimately keeps an empty `epcred` family (the §13.1 fence is issuance-only)
3509
+ * and the serve connection is bare — the broker enforces nothing on an open mesh, exactly the
3510
+ * old open-mesh ctl trust ("open = single-trusted-host"). */
3511
+ async registerManagerService() {
3512
+ const auth = this.auth;
3513
+ // The §13.7 contract store is REGISTRATION's dependency, ensured here MODE-NEUTRALLY (1c.2c):
3514
+ // it used to ride the static-only lifecycle reconcile, so a USER-mode manager registered
3515
+ // against an absent stream and its artifact publish died no-responders (live-repro'd). A
3516
+ // provisioner one-shot creates-or-verifies it (config-B immutability incl. the shadowed-legacy
3517
+ // refuse) before the executor publishes a single artifact.
3518
+ {
3519
+ // Open mesh: the bare connection holds the rights (there is no credential system to mint from).
3520
+ const provCreds = auth ? await mintCreds(auth, newIdentity(), "provisioner") : undefined;
3521
+ const provNc = await connect({ servers: this.servers ?? DEFAULT_SERVER, ...standaloneConnectOpts({ creds: provCreds, /* not yet wired to a recorded transport */ tls: false }), maxReconnectAttempts: 0 });
3522
+ try {
3523
+ // P2 item 2: the manager now WRITES goal facts (EPF) + progress events (EPE), so the §13.12
3524
+ // endpoint streams must exist. Nothing provisioned them before spawn-as-action (no endpoint
3525
+ // wrote to EPF/EPE), so the manager ensures the full set here over the provisioner (whose
3526
+ // STREAM.CREATE now covers them), idempotently - createEndpointStreams is a superset of
3527
+ // ensureContractStore + ensureAuthorityStores, fail-loud on drift. Auth + open both run this.
3528
+ await createEndpointStreams(await jetstreamManager(provNc), new Kvm(provNc), this.space);
3529
+ }
3530
+ finally {
3531
+ await provNc.drain().catch(() => provNc.close());
3532
+ }
3533
+ }
3534
+ const iid = this.managerInstanceId;
3535
+ const artifacts = managerClusterArtifacts();
3536
+ // In-memory §13.7 content store: the manager is this document's AUTHOR, so registration and
3537
+ // serve authorization verify against the exact artifacts it publishes from memory. The DURABLE
3538
+ // `epc` contract-store publication (for third-party digest fetches) runs below inside the same
3539
+ // executor, BEFORE the registration that advertises the digests.
3540
+ const store = new Map([
3541
+ [artifacts.rootDigest, artifacts.document],
3542
+ [artifacts.closureDigest, artifacts.manifest],
3543
+ ]);
3544
+ const readClusterArtifact = (digest) => store.get(digest);
3545
+ // §13.9 name authority, static mode: `manager` is a core single-label name requiring OPERATOR
3546
+ // authority — the manager holds the space signing seed, so it self-authorizes exactly its own
3547
+ // name for exactly DEV_OWNER (never a general authority; any other (name, owner) refuses).
3548
+ const authority = {
3549
+ authorize: (name, owner) => ({ authorized: name === MANAGER_ENDPOINT && owner === DEV_OWNER, revision: 0 }),
3550
+ };
3551
+ // The STABLE serve identity (P2 item 3): the PERSISTED serve nkey, reused across restart so the
3552
+ // gate binds the SAME principal (§13.1 serving-principal binding) - provisionEndpointGateOpen
3553
+ // stays idempotent and verified eviction has a stable target. Renewals re-mint the same nkey
3554
+ // with a fresh bounded exp; a restart re-provisions the same (idempotent) gate + re-registers.
3555
+ const serveIdentity = this.managerServeIdentity;
3556
+ const servePrincipal = principalKey(DEV_OWNER, serveIdentity.id).key;
3557
+ // must-5 (b): the STABLE goal-writer identity — a SIBLING credential in the same §13.1 family
3558
+ // (not the gate's bound serving principal), minted here so the run block can family-stage it.
3559
+ this.goalWriterIdentity = newIdentity();
3560
+ // P2 item 6: the STABLE session-LEDGER identity — another SIBLING in the SAME §13.1 family, so
3561
+ // the takeover barrier revokes a deposed manager's ledger cred alongside its goal-writer. The
3562
+ // per-session serving creds join the same family, each with its own fresh identity.
3563
+ this.sessionLedgerIdentity = newIdentity();
3564
+ const run = async ({ recordsKv, authKv, nc: execNc }) => {
3565
+ // §13.7 contract-artifact publication (1c): every schema root + its closure manifest, plus
3566
+ // the cluster document + ITS manifest, land in the EPC store BEFORE the registration that
3567
+ // advertises their digests — so a caller can always fetch-verify-compile a registered
3568
+ // digest (the item-5 generic-invoke read path). Create-only + content-addressed: a retry
3569
+ // or a same-artifact republish is an idempotent lost-CAS. The registration itself still
3570
+ // verifies against the in-memory copies (the manager is the author).
3571
+ const storeCtx = await contractStoreContext(execNc, this.space);
3572
+ for (const value of [...managerContractArtifactValues(), artifacts.document, artifacts.manifest])
3573
+ await publishContractArtifact(storeCtx, contractArtifactCanonicalBytes(value));
3574
+ // §13.1 pre-registration (checklist 1): the issuance gate, born open@gen0 bound to the serve
3575
+ // principal — provisioned ONCE, on the FIRST registration. On a RESTART (P2 item 3) the gate
3576
+ // already EXISTS (advanced past gen0 by the prior registration), so re-provisioning the gen0
3577
+ // row would conflict "foreign content"; the persisted instanceId makes this a TAKEOVER, and
3578
+ // registerServiceInstance below freezes + re-registers the EXISTING gate (advancing the epoch
3579
+ // after it verify-evicts the superseded family). The serve principal is the SAME persisted one,
3580
+ // so the gate's principal binding is unchanged either way.
3581
+ if ((await serveIssuanceGateKv(authKv, this.space, { endpoint: MANAGER_ENDPOINT, instanceId: iid }).observe()) === null)
3582
+ await provisionEndpointGateOpen(authKv, { endpoint: MANAGER_ENDPOINT, instanceId: iid, principal: servePrincipal });
3583
+ // P2 item 3 (slice 3a): on an AUTH mesh a RE-registration (restart of the persisted instanceId)
3584
+ // must VERIFY-EVICT the superseded serve family BEFORE the epoch advances (§13.1 "old authority
3585
+ // dies before new authority is visible"). Inject the SCOPED delivery-admin evictor; the OPEN
3586
+ // mesh mints no serve family, so no evictor (the empty-family path never consults it and a
3587
+ // restart there works evictor-free). NO-ORACLE = LOUD: with no reachable delivery daemon the
3588
+ // evictor THROWS naming the cure, so PHASE 2 fails closed with the delivery-daemon fix in the
3589
+ // error text — a crash-restart never silently skips eviction (no-fallbacks).
3590
+ const barrier = endpointRegistrationBarrier(authKv, this.space, {
3591
+ endpoint: MANAGER_ENDPOINT, instanceId: iid, opId: mintLifecycleUid(),
3592
+ ...(auth ? { evict: makeManagerEndpointEvictor({ space: this.space, servers: this.servers ?? DEFAULT_SERVER, auth, log: (line) => console.error(line) }) } : {}),
3593
+ });
3594
+ const spec = { endpoint: MANAGER_ENDPOINT, owner: DEV_OWNER, clusterDigests: [artifacts.closureDigest], protocol: { v: 1 } };
3595
+ const { registrationRevision } = await registerServiceInstance(recordsKv, {
3596
+ space: this.space, spec, instanceId: iid, registrant: { owner: DEV_OWNER }, authority, barrier, readClusterArtifact,
3597
+ });
3598
+ // processEpoch comes from the GATE (checklist 4: never derived from the uid string); the
3599
+ // fence below is also the mint's §13.1 release CAS.
3600
+ const fence = serveIssuanceGateKv(authKv, this.space, { endpoint: MANAGER_ENDPOINT, instanceId: iid });
3601
+ const observed = await fence.observe();
3602
+ if (observed === null)
3603
+ throw new Error(`the issuance gate for ${MANAGER_ENDPOINT}/${iid} vanished after registration`);
3604
+ const grant = await authorizeServeGrant(recordsKv, {
3605
+ space: this.space, endpoint: MANAGER_ENDPOINT, instanceId: iid, epoch: observed.processEpoch,
3606
+ holder: { owner: DEV_OWNER }, authority, readClusterArtifact,
3607
+ readProcessEpoch: async () => {
3608
+ const g = await fence.observe();
3609
+ if (g === null)
3610
+ throw new Error(`no issuance gate for ${MANAGER_ENDPOINT}/${iid}`);
3611
+ return g.processEpoch;
3612
+ },
3613
+ });
3614
+ // P2 item 3 (class scatter): write this instance's CONVERGED svc status so it is a §13.5
3615
+ // scatter member — `freezeExpectedSet` skips any instance whose status is absent or lags the
3616
+ // current registration. Instance-side `ready` at the just-registered spec revision, epoch-fenced
3617
+ // to the gate's processEpoch (the same leader-served reader `authorizeServeGrant` used); on a
3618
+ // restart it CAS-updates the predecessor's status forward (the advanced epoch supersedes the old
3619
+ // one). Key-pinned to this instance's own status key on the SAME executor.
3620
+ await writeServiceStatus(recordsKv, {
3621
+ endpoint: MANAGER_ENDPOINT, instanceId: iid, epoch: observed.processEpoch,
3622
+ status: { state: SERVICE_READY, epoch: observed.processEpoch, observedSpecRevision: registrationRevision },
3623
+ readProcessEpoch: async () => {
3624
+ const g = await fence.observe();
3625
+ if (g === null)
3626
+ throw new Error(`no issuance gate for ${MANAGER_ENDPOINT}/${iid}`);
3627
+ return g.processEpoch;
3628
+ },
3629
+ });
3630
+ // Open mesh: NO mint - the §13.1 fence is issuance-only and nothing is ever issued, so the
3631
+ // gate keeps an empty `epcred` family; the serve connection below stays bare.
3632
+ const creds = auth ? await mintCreds(auth, serveIdentity, "endpoint-serve", { serveIssuance: fence, endpointServe: grant }) : undefined;
3633
+ // must-5 (b): mint + family-stage the goal-writer credential HERE, over this executor's
3634
+ // authKv (the fence is live), so its credId lands in `epcred.<e>.<iid>` and the takeover
3635
+ // barrier revokes it. Open mesh: no mint (no credential system; the goal-writer conn is bare).
3636
+ const goalWriterCreds = auth ? await this.mintAndStageGoalWriter(authKv) : undefined;
3637
+ // P2 item 6: mint + family-stage the session-LEDGER credential HERE too (same executor, same
3638
+ // fence, same §13.1 family), so takeover revokes a deposed manager's ledger connection. The
3639
+ // per-session SERVING credentials are minted later, one per redemption, into the SAME family.
3640
+ // Open mesh: no mint (no credential system; the ledger connection stays bare).
3641
+ const sessionLedgerCreds = auth ? await this.mintAndStageSessionLedger(authKv) : undefined;
3642
+ return { grant, creds, goalWriterCreds, sessionLedgerCreds };
3643
+ };
3644
+ const { grant, creds, goalWriterCreds, sessionLedgerCreds } = await (auth ? this.withEndpointServeExecutor(run) : this.withOpenServeConnection(run));
3645
+ this.goalWriterCreds = goalWriterCreds;
3646
+ this.sessionLedgerCreds = sessionLedgerCreds;
3647
+ // The serve connection presents the CURRENT credential on every (re)connect (the state object
3648
+ // is captured by the authenticator), so a fence-traversing renewal is adopted by reconnect
3649
+ // without re-registration. Reconnects stay unbounded: the serve rails are this instance's
3650
+ // registered surface for its whole incarnation.
3651
+ const state = { handle: undefined, nc: undefined, identity: serveIdentity, grant, creds };
3652
+ const enc = new TextEncoder();
3653
+ const nc = await connect({
3654
+ servers: this.servers ?? DEFAULT_SERVER,
3655
+ // Open mesh: a bare serve connection (no credential exists; the broker enforces nothing).
3656
+ ...(creds !== undefined ? { authenticator: (nonce) => credsAuthenticator(enc.encode(state.creds))(nonce) } : {}),
3657
+ inboxPrefix: `_INBOX_${serveIdentity.id}`,
3658
+ maxReconnectAttempts: -1,
3659
+ });
3660
+ nc.closed().then((err) => { if (err)
3661
+ console.error(`! manager service endpoint connection closed: ${err.message}`); });
3662
+ try {
3663
+ // The 1b typed surface + the derived `describe`. The descriptor stays PUBLIC in static
3664
+ // mode: the broker grant (who holds each command's request-publish row) is the
3665
+ // load-bearing authority tier, and a static single-operator mesh leaks nothing by listing
3666
+ // command names; the trusted per-caller `view(caller)` scoping joins the user-mode
3667
+ // registration follow-up (where actorScope is the trusted source). Every ordinary handler
3668
+ // runs the SHARED admission chokepoint ({@link serveGated}).
3669
+ state.handle = serveEndpoint(nc, this.space, grant, this.managerServiceDefs(), { public: true }, {
3670
+ // The FRESH target resolver (§13.3) for the targeted commands (`despawn`/`attach`): the
3671
+ // manager's live managed set IS the current-mapping authority for its own agents (the
3672
+ // durable slot rows mirror it). Static mode carries no mapping-revision dimension, so
3673
+ // the revision is the constant 0 — a caller that pins a revision pins 0.
3674
+ resolveTarget: (t) => {
3675
+ if (t.owner === DEV_OWNER) {
3676
+ for (const a of this.agents.values())
3677
+ if (!a.userOwner && a.id === t.actor)
3678
+ return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
3679
+ return undefined;
3680
+ }
3681
+ const key = principalKey(t.owner, t.actor).key;
3682
+ for (const a of this.agents.values())
3683
+ if (a.userOwner && a.id === key)
3684
+ return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
3685
+ return undefined;
3686
+ },
3687
+ });
3688
+ }
3689
+ catch (e) {
3690
+ await nc.drain().catch(() => nc.close());
3691
+ throw e;
3692
+ }
3693
+ state.nc = nc;
3694
+ this.serviceServe = state;
3695
+ console.error(`manager service endpoint registered: ${MANAGER_ENDPOINT}/${iid} (epoch ${grant.epoch}, registrationRevision ${grant.registrationRevision})`);
3696
+ }
3697
+ /** P2 item 2 must-5 (b): mint the standing `goal-writer` credential and STAGE it into this
3698
+ * instance's §13.1 revocation family (`epcred.<e>.<iid>`), over the passed executor's `authKv`
3699
+ * (the scoped `endpoint-serve-executor`, which holds the epcred write grant). The GRANT profile
3700
+ * stays goal-writer-only (Q2 — disjoint from the serve credential); only the FAMILY membership
3701
+ * is shared, so the registration barrier's existing enumerate+revoke+evict catches the
3702
+ * goal-writer on takeover/retire with NO barrier code change. Used both at registration (the run
3703
+ * block's `authKv`) and at renewal (a fresh executor's), re-minting the SAME stable nkey with a
3704
+ * fresh bounded exp — each issuance writes a DISTINCT ledger row (per-JWT credentialId digest). */
3705
+ async mintAndStageGoalWriter(authKv) {
3706
+ const auth = this.auth;
3707
+ const identity = this.goalWriterIdentity;
3708
+ // The issuance gate + §13.1 revocation family are keyed by the REGISTRATION instanceId
3709
+ // (the persisted logical id, item 3's split), NOT the per-process lifecycleUid — the barrier
3710
+ // enumerates `epcred.<e>.<managerInstanceId>`, so the goal-writer must stage into that family.
3711
+ const iid = this.managerInstanceId;
3712
+ const creds = await mintCreds(auth, identity, "goal-writer", { goalWriter: { endpoint: MANAGER_ENDPOINT } });
3713
+ const exp = inspectCredHealth(creds).exp;
3714
+ if (exp === undefined)
3715
+ throw new Error(`the goal-writer credential for ${MANAGER_ENDPOINT}/${iid} is unbounded; the §13.1 ledger row requires an expiry`);
3716
+ const fence = serveIssuanceGateKv(authKv, this.space, { endpoint: MANAGER_ENDPOINT, instanceId: iid });
3717
+ const observed = await fence.observe();
3718
+ if (observed === null)
3719
+ throw new Error(`the issuance gate for ${MANAGER_ENDPOINT}/${iid} vanished; the goal-writer cannot join its §13.1 revocation family`);
3720
+ // The §13.1 open-and-commit fence, the SAME one the serve credential's own mint runs: a frozen
3721
+ // gate refuses, and a gate that moved under us loses the CAS and revokes the staged row. Without
3722
+ // it a barrier mid-takeover (already past its enumeration) would let this credential land
3723
+ // ACTIVE in a family nothing revokes again.
3724
+ await commitSiblingIssuance(fence, observed, {
3725
+ credentialId: rawDigest(creds).replace("sha256:", "sha256-"),
3726
+ credentialKey: identity.id,
3727
+ holderPrincipal: principalKey(DEV_OWNER, identity.id).key,
3728
+ endpoint: MANAGER_ENDPOINT, lifecycleUid: iid, sourceChain: ["root"], state: "active", exp,
3729
+ generation: observed.generation, processEpoch: observed.processEpoch,
3730
+ registrationRevision: observed.registrationRevision, nameAuthorityRevision: observed.nameAuthorityRevision,
3731
+ });
3732
+ return creds;
3733
+ }
3734
+ /** P2 item 2 (spawn-as-action): stand up the standing self-mediated goal-writer connection +
3735
+ * ActionContext. Mode-dual, mirroring {@link registerManagerService}: an AUTH mesh uses the
3736
+ * scoped `goal-writer` credential already minted + family-STAGED inside registration's run block
3737
+ * ({@link mintAndStageGoalWriter} — DISJOINT grant from the serve cred, SHARED §13.1 revocation
3738
+ * family); an OPEN mesh uses a bare connection (no credential system to mint from - the broker
3739
+ * enforces nothing). The connection presents the CURRENT credential on every (re)connect (a
3740
+ * renewal is adopted without reconnecting the whole endpoint); the ActionContext bonds its
3741
+ * KV + JS + JSM to this one connection and space (SPEC 13.4), so a composition mixup cannot splice
3742
+ * goal state across brokers. */
3743
+ async startGoalWriter() {
3744
+ const identity = this.auth ? this.goalWriterIdentity : newIdentity();
3745
+ const enc = new TextEncoder();
3746
+ // The mutable holder captured by the authenticator (mirrors the serve connection): a half-TTL
3747
+ // renewal updates `gw.creds` and the next (re)connect presents the refreshed credential.
3748
+ const gw = { nc: undefined, ctx: undefined, creds: this.auth ? this.goalWriterCreds : undefined, identity };
3749
+ const nc = await connect({
3750
+ servers: this.servers ?? DEFAULT_SERVER,
3751
+ ...(this.auth ? { authenticator: (nonce) => credsAuthenticator(enc.encode(gw.creds))(nonce) } : {}),
3752
+ inboxPrefix: `_INBOX_${identity.id}`,
3753
+ maxReconnectAttempts: -1,
3754
+ });
3755
+ nc.closed().then((err) => { if (err)
3756
+ console.error(`! manager goal-writer connection closed: ${err.message}`); });
3757
+ gw.nc = nc;
3758
+ // The (i) fence resolver (SPEC 13.6 P2 item 3): resolve an executing instance's CURRENT gate
3759
+ // epoch. This manager reconciles only ITS OWN goals (security pin 4), so it resolves its own
3760
+ // registration instanceId to this incarnation's serve-grant epoch; a foreign/retired instance
3761
+ // is `null` (no current terminal to surface). A successor incarnation carries an ADVANCED epoch
3762
+ // here, so its reads pick the current-epoch subject and the predecessor's terminal is fenced out.
3763
+ gw.ctx = await actionContext(nc, this.space);
3764
+ // The own-issuance-gate READER for the currency belt, on BOTH mesh modes. Reads
3765
+ // `epgate.<e>.<iid>` over this connection; observe-only (stage/commit/revoke are never called
3766
+ // through it — the goal-writer holds no gate/epcred WRITE grant, so a mis-call would
3767
+ // broker-deny anyway).
3768
+ //
3769
+ // OPEN MESH IS NOT EXEMPT, and this is load-bearing. An open-mesh registration mints no
3770
+ // credentials, so the §13.1 takeover barrier's revoke-and-evict loop enumerates an EMPTY family
3771
+ // and is VACUOUS — yet it still advances processEpoch. The gate row itself DOES exist there
3772
+ // (`provisionEndpointGateOpen`, over the authority stores the open serve path ensures), and a
3773
+ // bare connection CAN read it (executed probe). So this belt is the ONLY thing standing between
3774
+ // a deposed open-mesh incarnation and a wrong terminal on the one create-only result subject.
3775
+ //
3776
+ // NAME IT HONESTLY: on an open mesh this is a COOPERATIVE fence. The broker enforces nothing, so
3777
+ // a hostile or non-conformant process can simply not run the check — the same guarantee class as
3778
+ // every other open-mesh property. It is also a read-then-CAS, so it NARROWS the commit window
3779
+ // rather than closing it. An AUTH mesh gets durable closure from the §13.1 barrier (revoke +
3780
+ // cluster-verified eviction BEFORE the epoch advance); an open mesh gets none.
3781
+ gw.gate = serveIssuanceGateKv(await new Kvm(nc).open(epAuthBucket(this.space)), this.space, { endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId });
3782
+ this.goalWriter = gw;
3783
+ console.error(`manager goal-writer standing (endpoint ${MANAGER_ENDPOINT}, ${this.auth ? "scoped cred, §13.1 family-staged" : "open/bare"})`);
3784
+ }
3785
+ /** Drain the goal-writer connection (best-effort, both exit paths). */
3786
+ async stopGoalWriter() {
3787
+ const gw = this.goalWriter;
3788
+ if (!gw)
3789
+ return;
3790
+ this.goalWriter = undefined;
3791
+ try {
3792
+ await gw.nc.drain();
3793
+ }
3794
+ catch {
3795
+ try {
3796
+ gw.nc.close();
3797
+ }
3798
+ catch { /* best effort */ }
3799
+ }
3800
+ }
3801
+ /** P2 item 6: mint the standing `session-ledger` credential and STAGE it into this instance's
3802
+ * §13.1 revocation family (`epcred.<e>.<iid>`), over the passed executor's `authKv` — EXACTLY the
3803
+ * {@link mintAndStageGoalWriter} pattern, under the same open-and-commit fence.
3804
+ *
3805
+ * This credential carries the DEDICATED sessions-bucket ledger rows and no session rail at all.
3806
+ * It therefore takes no epoch pin: §13.6 makes it the durable revocation authority that must
3807
+ * outlive the serving endpoint, so scoping it to one serving epoch would defeat its purpose. The
3808
+ * epoch lives where it belongs, on the per-session serving credentials, which are minted per
3809
+ * redemption into this same family. Re-minting the SAME nkey on renewal writes a DISTINCT ledger
3810
+ * row (per-JWT credentialId digest). */
3811
+ async mintAndStageSessionLedger(authKv) {
3812
+ const auth = this.auth;
3813
+ const identity = this.sessionLedgerIdentity;
3814
+ // Registration instanceId (item 3's persisted logical id), not the per-process lifecycleUid:
3815
+ // the barrier enumerates `epcred.<e>.<managerInstanceId>`, so the ledger cred joins that family.
3816
+ const iid = this.managerInstanceId;
3817
+ const fence = serveIssuanceGateKv(authKv, this.space, { endpoint: MANAGER_ENDPOINT, instanceId: iid });
3818
+ const observed = await fence.observe();
3819
+ if (observed === null)
3820
+ throw new Error(`the issuance gate for ${MANAGER_ENDPOINT}/${iid} vanished; the session-ledger cred cannot join its §13.1 revocation family`);
3821
+ const creds = await mintCreds(auth, identity, "session-ledger");
3822
+ const exp = inspectCredHealth(creds).exp;
3823
+ if (exp === undefined)
3824
+ throw new Error(`the session-ledger credential for ${MANAGER_ENDPOINT}/${iid} is unbounded; the §13.1 ledger row requires an expiry`);
3825
+ // The §13.1 open-and-commit fence (see {@link mintAndStageGoalWriter}): stage + revision-pinned
3826
+ // commit against the gate this mint's grant was scoped from, releasing only on the win.
3827
+ await commitSiblingIssuance(fence, observed, {
3828
+ credentialId: rawDigest(creds).replace("sha256:", "sha256-"),
3829
+ credentialKey: identity.id,
3830
+ holderPrincipal: principalKey(DEV_OWNER, identity.id).key,
3831
+ endpoint: MANAGER_ENDPOINT, lifecycleUid: iid, sourceChain: ["root"], state: "active", exp,
3832
+ generation: observed.generation, processEpoch: observed.processEpoch,
3833
+ registrationRevision: observed.registrationRevision, nameAuthorityRevision: observed.nameAuthorityRevision,
3834
+ });
3835
+ return creds;
3836
+ }
3837
+ /** P2 item 6: stand up the ONE §13.6 session plane on its own standing connection. Mode-dual,
3838
+ * mirroring {@link startGoalWriter}: an AUTH mesh presents the scoped `session-ledger` cred
3839
+ * already minted + family-staged inside registration's run block ({@link mintAndStageSessionLedger});
3840
+ * an OPEN mesh uses a bare connection (no credential system to mint from). The connection presents
3841
+ * the CURRENT credential on every (re)connect, so a half-TTL renewal is adopted without reconnecting.
3842
+ *
3843
+ * The offer SIGNER is a per-incarnation in-memory keypair: the static collapsed path mints AND
3844
+ * redeems the offer in one call ({@link ManagerSessionPlane.establishAttach}), so the manager
3845
+ * self-signs and self-verifies its own §13.6 grants and the keypair never leaves the process — a
3846
+ * holder never verifies the signature (it presents the grant back over the rail; the broker's
3847
+ * per-session caller cred is the holder's real fence). The plane's ledger lives in the DEDICATED
3848
+ * sessions bucket (createEndpointStreams provisioned it at registration). */
3849
+ async startSessionPlane() {
3850
+ const identity = this.auth ? this.sessionLedgerIdentity : newIdentity();
3851
+ const enc = new TextEncoder();
3852
+ // The mutable holder captured by the authenticator (mirrors the goal-writer): a half-TTL renewal
3853
+ // updates `sw.creds` and the next (re)connect presents the refreshed credential.
3854
+ const sw = { nc: undefined, creds: this.auth ? this.sessionLedgerCreds : undefined };
3855
+ const nc = await connect({
3856
+ servers: this.servers ?? DEFAULT_SERVER,
3857
+ ...(this.auth ? { authenticator: (nonce) => credsAuthenticator(enc.encode(sw.creds))(nonce) } : {}),
3858
+ inboxPrefix: `_INBOX_${identity.id}`,
3859
+ maxReconnectAttempts: -1,
3860
+ });
3861
+ nc.closed().then((err) => { if (err)
3862
+ console.error(`! manager session-ledger connection closed: ${err.message}`); });
3863
+ sw.nc = nc;
3864
+ const serveEpoch = this.serviceServe?.grant.epoch;
3865
+ if (serveEpoch === undefined)
3866
+ throw new Error("the manager session plane needs the serve grant epoch; registerManagerService must run first");
3867
+ const ledgerKv = await openSessionLedgerKv(nc, sessionsBucket(this.space));
3868
+ const signer = newArtifactSigner();
3869
+ const keyId = `mgr-sessions-${identity.id.slice(0, 12)}`;
3870
+ const anchor = {
3871
+ keyId, publicKey: signer.publicKey, owner: MANAGER_ENDPOINT, roles: ["sessions"],
3872
+ scope: { sessions: [MANAGER_ENDPOINT] }, validFrom: Date.now() - 60_000, validTo: Date.now() + SESSION_GRANT_MAX_TTL_MS,
3873
+ };
3874
+ this.sessionPlane = new ManagerSessionPlane({
3875
+ space: this.space,
3876
+ // The session's serving identity is the persisted REGISTRATION instanceId (item 3), not the
3877
+ // per-process lifecycleUid: a restarted manager re-registers the SAME logical instanceId with
3878
+ // an ADVANCED epoch, so a client re-attaches by the same instance while the epoch fences the
3879
+ // old incarnation's sessions (item 6's restart-refusal composed with item 3's addressing).
3880
+ serving: { instanceId: this.managerInstanceId, epoch: serveEpoch },
3881
+ signer: { keyId, keyPair: signer }, resolveAnchor: (id) => (id === keyId ? anchor : undefined),
3882
+ ledgerKv, ttlMs: SESSION_GRANT_MAX_TTL_MS,
3883
+ servingCredential: this.sessionServingCredentials(),
3884
+ ...(this.maxSessions !== undefined ? { maxSessions: this.maxSessions } : {}),
3885
+ });
3886
+ this.sessionLedgerConn = sw;
3887
+ console.error(`manager session plane standing (endpoint ${MANAGER_ENDPOINT}, epoch ${serveEpoch}, ${this.auth ? "scoped session-ledger cred, §13.1 family-staged" : "open/bare"})`);
3888
+ }
3889
+ /**
3890
+ * The per-session SERVING credential seam (P2 item 6, SPEC 13.6): the manager mints, gate-stages,
3891
+ * connects and revokes ONE credential per live session, replacing a standing credential that held
3892
+ * `eps.manager.*.<epoch>.{in,out}` and so reached every live session's bytes at its epoch.
3893
+ *
3894
+ * Each session gets its OWN nkey identity, so the §13.1 barrier's evict-by-holderPrincipal reaches
3895
+ * it individually, and each is staged into `epcred.manager.<instanceId>` — the SAME family the
3896
+ * ledger and goal-writer creds join. That is how manager takeover still kills a deposed manager's
3897
+ * sessions: the barrier enumerates the family, revokes every row, and evicts every holder, so the
3898
+ * per-session creds die with the incarnation exactly as the standing one did, with the blast
3899
+ * radius of a leaked credential cut from "every session at this epoch" to "one dead session".
3900
+ *
3901
+ * OPEN MESH: no credential system exists to mint from, so the seam mints nothing and opens a bare
3902
+ * connection. That is not a degraded auth path — an open mesh has no broker enforcement at all —
3903
+ * and it is still per-session: the connection and the ledger row are still one-per-session, so
3904
+ * teardown behaves identically in both modes.
3905
+ */
3906
+ sessionServingCredentials() {
3907
+ const iid = this.managerInstanceId;
3908
+ const gate = async (fn) => this.withEndpointServeExecutor(({ authKv }) => fn(authKv));
3909
+ return {
3910
+ mint: async (grant) => {
3911
+ // Open mesh: no auth to mint from. The id still names the session so the ledger row and the
3912
+ // teardown path are identical in both modes.
3913
+ if (!this.auth)
3914
+ return { id: `${grant.sessionId}.s`, creds: "", exp: grant.exp };
3915
+ const identity = newIdentity();
3916
+ const creds = await mintCreds(this.auth, identity, "session-serving", {
3917
+ sessionServing: { endpoint: grant.endpoint, sessionId: grant.sessionId, epoch: grant.serving.epoch },
3918
+ expiresAt: Math.floor(grant.exp / 1000), // grant.exp is ms; the JWT exp is seconds
3919
+ });
3920
+ const health = inspectCredHealth(creds);
3921
+ if (health.exp === undefined)
3922
+ throw new Error(`the session-serving credential for ${grant.sessionId} is unbounded; a per-session credential never outlives its session (SPEC 13.6)`);
3923
+ this.sessionServingKeys.set(rawDigest(creds).replace("sha256:", "sha256-"), identity.id);
3924
+ return { id: rawDigest(creds).replace("sha256:", "sha256-"), creds, exp: grant.exp };
3925
+ },
3926
+ observeGate: async (_endpoint, instanceId) => {
3927
+ // Open mesh: nothing is minted and nothing is staged, so there is no gate to pin (see
3928
+ // ServingGatePin.gate — the stage refuses loudly if this is ever missing on an auth mesh).
3929
+ if (!this.auth)
3930
+ return { key: epgateKey(MANAGER_ENDPOINT, instanceId), revision: 0 };
3931
+ return gate(async (authKv) => {
3932
+ const observed = await serveIssuanceGateKv(authKv, this.space, { endpoint: MANAGER_ENDPOINT, instanceId }).observe();
3933
+ if (observed === null)
3934
+ throw new Error(`the issuance gate for ${MANAGER_ENDPOINT}/${instanceId} vanished; a session credential never stages against a missing gate (SPEC 13.1)`);
3935
+ // The WHOLE observation rides the pin: the stage's fence compares every field of it, which
3936
+ // is what lets a lost CAS be classified rather than blanket-refused.
3937
+ return { key: epgateKey(MANAGER_ENDPOINT, instanceId), revision: observed.revision, gate: observed };
3938
+ });
3939
+ },
3940
+ stage: async (grant, cred, pin) => {
3941
+ if (!this.auth)
3942
+ return; // open mesh: nothing minted, so nothing to make revocable
3943
+ const key = this.sessionServingKeys.get(cred.id);
3944
+ if (key === undefined)
3945
+ throw new Error(`no minted identity for session credential ${cred.id}; the stage cannot record a holder it did not mint (SPEC 13.1)`);
3946
+ // THE REDEMPTION'S OWN PIN IS THE FENCE, and it is never re-read into something newer here:
3947
+ // `commitSiblingIssuance` CASes on this observation's revision and, on a loss, refuses unless
3948
+ // the gate is still identical to it in every field but the revision. A barrier
3949
+ // (freeze, or reopen at a successor coordinate) is therefore always a refusal, while another
3950
+ // session's identical-bytes commit touch is not — per-session credentials all serialize on
3951
+ // this one gate key, so refusing on that would fail live sessions for contention rather than
3952
+ // for a barrier. A read is never a fence (SPEC 13.1); the pinned CAS is.
3953
+ const observed = pin.gate;
3954
+ if (observed === undefined)
3955
+ throw new Error(`the redemption of session ${grant.sessionId} carries no gate observation for ${MANAGER_ENDPOINT}/${iid}; a session credential never stages unfenced (SPEC 13.1)`);
3956
+ await gate(async (authKv) => {
3957
+ const fence = serveIssuanceGateKv(authKv, this.space, { endpoint: MANAGER_ENDPOINT, instanceId: iid });
3958
+ await commitSiblingIssuance(fence, observed, {
3959
+ credentialId: cred.id,
3960
+ credentialKey: key,
3961
+ holderPrincipal: principalKey(DEV_OWNER, key).key,
3962
+ endpoint: MANAGER_ENDPOINT, lifecycleUid: iid,
3963
+ // The lineage records that this credential exists because a session was redeemed, so a
3964
+ // ledger reader can tell a per-session row from a standing one (SPEC 13.6 sourceChain).
3965
+ sourceChain: [`session.${grant.sessionId}`], state: "active",
3966
+ exp: Math.floor(cred.exp / 1000),
3967
+ generation: observed.generation, processEpoch: observed.processEpoch,
3968
+ registrationRevision: observed.registrationRevision, nameAuthorityRevision: observed.nameAuthorityRevision,
3969
+ });
3970
+ });
3971
+ },
3972
+ open: async (cred) => {
3973
+ // FAIL LOUD: there is deliberately no shared connection to fall back to. Serving a session
3974
+ // without its own credential is exactly the standing-writer shape this design removes.
3975
+ const opts = this.auth ? standaloneConnectOpts({ creds: cred.creds, /* not yet wired to a recorded transport */ tls: false }) : {};
3976
+ return connect({ servers: this.servers ?? DEFAULT_SERVER, ...opts, maxReconnectAttempts: -1 });
3977
+ },
3978
+ revoke: async (credentialId) => {
3979
+ if (!this.auth)
3980
+ return; // open mesh: nothing was minted
3981
+ await gate(async (authKv) => {
3982
+ await markLedgerRowRevoked(authKv, epcredRowKey(MANAGER_ENDPOINT, iid, credentialId));
3983
+ });
3984
+ this.sessionServingKeys.delete(credentialId);
3985
+ },
3986
+ };
3987
+ }
3988
+ /** Tear the session plane down (best-effort, both exit paths): end every live bridge with the
3989
+ * honest `manager-restart` reason (this incarnation is going away; any successor takes a new epoch
3990
+ * and refuses these grants), then drain each session's own connection and the ledger connection. */
3991
+ async stopSessionPlane() {
3992
+ const plane = this.sessionPlane;
3993
+ const sw = this.sessionLedgerConn;
3994
+ this.sessionPlane = undefined;
3995
+ this.sessionLedgerConn = undefined;
3996
+ // `drain` awaits each session's teardown (connection close, terminal row, credential revoke);
3997
+ // `endAll` alone would let the process exit with per-session connections still open.
3998
+ try {
3999
+ await plane?.drain("manager-restart");
4000
+ }
4001
+ catch { /* best effort */ }
4002
+ if (sw) {
4003
+ try {
4004
+ await sw.nc.drain();
4005
+ }
4006
+ catch {
4007
+ try {
4008
+ sw.nc.close();
4009
+ }
4010
+ catch { /* best effort */ }
4011
+ }
4012
+ }
4013
+ }
4014
+ /** P2 item 2 must-5 Q-B — the boot reconcile: a fresh incarnation (a manager restart takes a NEW
4015
+ * instanceId, so the in-memory acceptance map starts empty) inherits the endpoint's accepted-but-
4016
+ * unterminal goals from any predecessor. Enumerate the durable index over a scoped PROVISIONER
4017
+ * (records CONSUMER.CREATE; the goal-writer holds NO enumeration grant, exactly the ruling) and
4018
+ * settle each orphan so an accepted goal is NEVER dropped across a restart. Open mesh: a bare
4019
+ * connection (the broker enforces nothing). Runs ONCE at start, BEFORE spawn-as-action begins
4020
+ * accepting (the `goalReconcileDone` gate), so it never races a live goal's acceptance. Never
4021
+ * fatal — a reconcile failure is logged and the gate opens either way. */
4022
+ async reconcileGoalIndex() {
4023
+ const gw = this.goalWriter;
4024
+ if (!gw) {
4025
+ this.goalReconcileDone = true;
4026
+ return;
4027
+ }
4028
+ try {
4029
+ let entries = [];
4030
+ const nc = this.auth
4031
+ ? await connect({ servers: this.servers ?? DEFAULT_SERVER, ...standaloneConnectOpts({ creds: await mintCreds(this.auth, newIdentity(), "provisioner"), /* not yet wired to a recorded transport */ tls: false }), maxReconnectAttempts: 0 })
4032
+ : await connect({ servers: this.servers ?? DEFAULT_SERVER, maxReconnectAttempts: 0 });
4033
+ try {
4034
+ const kvm = new Kvm(nc);
4035
+ await ensureAuthorityStores(await jetstreamManager(nc), kvm, this.space);
4036
+ entries = await listGoalIndex(await kvm.open(recordsBucket(this.space)), MANAGER_ENDPOINT);
4037
+ }
4038
+ finally {
4039
+ await nc.drain().catch(() => nc.close());
4040
+ }
4041
+ // Single-manager item 2: EVERY inherited entry belongs to a DEAD predecessor (only one manager
4042
+ // at a time), so all are reconciled. The `iid` field is the hook item-3's multi-instance sweep
4043
+ // filters on (skip a goal whose accepting `iid` is a still-LIVE sibling — never settle its goal).
4044
+ for (const { ref, iid } of entries) {
4045
+ if (this.goalAcceptances.has(ref.goalId))
4046
+ continue; // never settle a goal THIS incarnation drives
4047
+ try {
4048
+ await this.reconcileOneGoal(ref, iid);
4049
+ }
4050
+ catch (e) {
4051
+ console.error(`! goal reconcile for ${ref.goalId}: ${e.message}`);
4052
+ }
4053
+ }
4054
+ if (entries.length)
4055
+ console.error(`goal-index boot reconcile: swept ${entries.length} inherited goal(s)`);
4056
+ }
4057
+ catch (e) {
4058
+ console.error(`! goal-index boot reconcile failed: ${e.message} - accepted goals from a predecessor may stay unsettled until the next restart`);
4059
+ }
4060
+ finally {
4061
+ this.goalReconcileDone = true;
4062
+ }
4063
+ }
4064
+ /** Settle ONE inherited goal by evidence: no goal record (a crash between the index write and the
4065
+ * goal-record create) leaves the pointer untouched (never settle a goal that was never accepted,
4066
+ * and clearing it would race a live goal mid-creation); a TERMINAL goal clears the index
4067
+ * (converged — the predecessor committed but died before clearing); a NON-TERMINAL goal settles
4068
+ * `uncertain` (the accepting incarnation is gone, so the success signal will never reach us — the
4069
+ * bounded readiness outcome the plan maps the window to). Within the readiness window it arms a
4070
+ * bounded, unref'd timer to settle at the deadline (an early uncertain would steal a still-possible
4071
+ * success the substrate guards against). */
4072
+ async reconcileOneGoal(ref, acceptedByIid) {
4073
+ const gw = this.goalWriter;
4074
+ if (!gw)
4075
+ return;
4076
+ const status = await readGoalStatus(gw.ctx, ref);
4077
+ if (status === undefined)
4078
+ return; // index points at no goal record: a dead pointer, left for honesty
4079
+ if (GOAL_TERMINAL_STATES.includes(status.value.state)) {
4080
+ await clearGoalIndex(gw.ctx, ref);
4081
+ return;
4082
+ }
4083
+ const spec = await readGoalSpec(gw.ctx, ref);
4084
+ if (spec === undefined)
4085
+ return; // a status without its spec is garbled — leave for the next boot
4086
+ // NEVER A CROSS-INSTANCE SETTLE. The accepting incarnation is recorded on the INDEX ENTRY
4087
+ // itself (`iid`, written at accept), which is the honest coordinate: a same-instanceId restart
4088
+ // inherits its predecessor's orphans, and a goal accepted by a DIFFERENT (possibly still-live)
4089
+ // sibling is left for its owner. This replaces the goal spec's `executor` pin, which existed
4090
+ // only to epoch-scope the terminal subject and is gone with it (SPEC:1394).
4091
+ if (acceptedByIid !== this.managerInstanceId) {
4092
+ console.error(`goal reconcile ${ref.goalId}: accepted by instance "${acceptedByIid}", not this incarnation "${this.managerInstanceId}"; left for its owner (never a cross-instance settle)`);
4093
+ return;
4094
+ }
4095
+ const settle = async () => {
4096
+ // A SUCCESSOR settling work it inherited: the committer is THIS incarnation at its CURRENT
4097
+ // serve epoch, which is strictly greater than the goal's acceptedEpoch — the `committed >
4098
+ // accepted` arm of the attribution rule, and the reason that arm has to exist.
4099
+ await settleGoalUncertain(gw.ctx, { ref, now: Date.now(), committer: { instanceId: this.managerInstanceId, epoch: this.serviceServe?.grant.epoch ?? 0 } }); // first-terminal-wins: a racing terminal returns the winner, no throw
4100
+ await clearGoalIndex(gw.ctx, ref);
4101
+ };
4102
+ const remaining = spec.value.acceptedAt + (spec.value.readinessDeadlineMs ?? this.readinessTimeoutMs) - Date.now();
4103
+ if (remaining <= 0) {
4104
+ await settle();
4105
+ return;
4106
+ }
4107
+ console.error(`goal reconcile ${ref.goalId}: within the readiness window (${remaining}ms) - arming a bounded settle`);
4108
+ const t = setTimeout(() => { settle().catch((e) => console.error(`! goal reconcile settle ${ref.goalId}: ${e.message}`)); }, remaining + 100);
4109
+ t.unref?.();
4110
+ }
4111
+ /** P2 item 2: publish a goal PROGRESS event on the caller-scoped epe subtree, over the SERVE
4112
+ * connection (which holds the `epe.<e>.<iid>.<epoch>.>` egress grant; the goal-writer deliberately
4113
+ * does not). The terminal rides a final event `phase:"terminal"` (Q1 — the caller follows epe to
4114
+ * the terminal; the durable result fact + inspect/ps are the reconcile authority). A dropped event
4115
+ * is non-fatal (the terminal is authoritative in the journal). */
4116
+ emitGoalProgress(ref, epoch, event) {
4117
+ const nc = this.serviceServe?.nc;
4118
+ if (!nc)
4119
+ return;
4120
+ try {
4121
+ nc.publish(epeSubject(this.space, MANAGER_ENDPOINT, this.managerInstanceId, epoch, goalProgressTopic(ref)), new TextEncoder().encode(JSON.stringify({ v: 1, goalId: ref.goalId, ...event })));
4122
+ }
4123
+ catch (e) {
4124
+ console.error(`! goal progress emit for ${ref.goalId} failed: ${e.message}`);
4125
+ }
4126
+ }
4127
+ /** The own-gate currency belt: before the goal-writer commits a terminal fact, the manager reads
4128
+ * its OWN issuance gate epoch and REFUSES the commit if superseded. This NARROWS the window; it
4129
+ * is not the fence. Layer 1 below is closed by the sibling-mint fence; layers 2 and 3 are not
4130
+ * closed by any planned slice and must not be described as temporary.
4131
+ *
4132
+ * THE RESIDUAL, STACKED:
4133
+ * 1. SIBLING-MINT INJECTION. A §13.1 barrier's revoke/evict loop closes only over the family
4134
+ * SNAPSHOT IT ENUMERATED, so a sibling mint that observes the gate and stages a ledger row
4135
+ * WITHOUT the observe/open/commit fence can be staged and released AFTER that enumerate and
4136
+ * never be revoked. This layer is closed exactly where BOTH sibling mint sites
4137
+ * ({@link mintAndStageGoalWriter}, {@link mintAndStageSessionLedger}) route their stage
4138
+ * through `commitSiblingIssuance` (the revision-pinned CAS that makes a losing mint release
4139
+ * nothing), and open exactly where they do not — state the mechanism, never the branch.
4140
+ * 2. THE BARRIER WINDOW. A gate FREEZE neither kills this connection nor advances the epoch,
4141
+ * and this belt compares `processEpoch` alone, so it still PASSES from barrier start until
4142
+ * the reopen. A ledger revoke marks a row; it does not re-check a live JWT mid-publish. The
4143
+ * durable kill is the CLUSTER-VERIFIED EVICTION, so a deposed manager can INITIATE new
4144
+ * terminal publishes from barrier start until eviction is verified — not merely finish bytes
4145
+ * already on the wire. Successor EXISTENCE and corpse DEATH are different phases, so the
4146
+ * barrier's ordering licenses no conclusion about when the corpse stops being able to write.
4147
+ * 3. OPEN MESH. No credential family exists, so the revoke/evict loop is vacuous and this belt
4148
+ * is COOPERATIVE only: a non-conformant process simply does not run it.
4149
+ * The named follow-up that would close 2 and 3 is the gate-linearized commit (routing the
4150
+ * terminal through the issuance gate's own CAS), deliberately deferred as substrate territory.
4151
+ * An earlier revision of this comment claimed the residual was "closed by item-3 slice 3.0,
4152
+ * never a permanent residual". That asserted a closure that does not exist. */
4153
+ async assertGoalWriterEpochCurrent(epoch) {
4154
+ const gate = this.goalWriter?.gate;
4155
+ if (!gate)
4156
+ return; // no goal-writer standing yet
4157
+ const observed = await gate.observe();
4158
+ if (observed === null)
4159
+ throw new EpEnvelopeError("expired", `the manager's issuance gate for ${MANAGER_ENDPOINT}/${this.managerInstanceId} is gone; a retired incarnation never commits a goal terminal (SPEC 13.1/13.6)`);
4160
+ if (observed.processEpoch !== epoch)
4161
+ throw new EpEnvelopeError("expired", `the manager's issuance gate epoch is ${observed.processEpoch} but this goal was accepted under epoch ${epoch}; a superseded incarnation never commits a goal terminal (must-5 (a) own-gate belt, SPEC 13.6)`);
4162
+ }
4163
+ /** Serve `spawn`/`launch` as an ACTION (P2 item 2). Authz already ran in {@link serveGated}. The
4164
+ * accept path runs INLINE on the handler ({@link startAgent} with hooks): the goal binds + the
4165
+ * acceptance replies the moment the identity is minted, BEFORE any provision (pin 1); progress and
4166
+ * the terminal are driven OFF-handler, so the ~30s readiness wait no longer blocks the reply.
4167
+ * Returns the acceptance floor payload {name, owner, actor, uid, goalId, fingerprint, executor}
4168
+ * (the ALLOCATED identity). goalId = the request id (env.id, Q3). */
4169
+ async serveSpawnGoal(ctx, run) {
4170
+ const gw = this.goalWriter;
4171
+ if (!gw)
4172
+ throw new EpEnvelopeError("unavailable", "the manager goal-writer connection is not standing; spawn-as-action cannot accept (SPEC 13.6)");
4173
+ // must-5 Q-B: refuse to accept until the boot reconcile of inherited goals completes, so a fresh
4174
+ // acceptance never races the sweep (settling a live goal mid-flight would steal its real terminal).
4175
+ if (!this.goalReconcileDone)
4176
+ throw new EpEnvelopeError("unavailable", "the manager is still reconciling accepted goals at boot; retry shortly (SPEC 13.6)");
4177
+ const goalId = ctx.request.id;
4178
+ const { fingerprint } = submissionFingerprint(ctx.request, ctx.subject);
4179
+ const ref = goalRefOf(ctx.subject, goalId);
4180
+ const executor = { lifecycleUid: this.managerInstanceId, epoch: this.serviceServe?.grant.epoch ?? 0 };
4181
+ const epoch = executor.epoch;
4182
+ const acceptedAt = Date.now();
4183
+ // Idempotent same-goalId retry (a client re-send): serve the IDENTICAL acceptance without
4184
+ // re-running the accept path — so a HARD-PINNED retry does not trip the M6 same-name refuse and no
4185
+ // name is re-allocated. Same-incarnation rides the live map; the create-only bindGoal in onAccepted
4186
+ // still fences a CONCURRENT same-goalId race (the loser aborts and serves the winner's acceptance).
4187
+ const prior = this.goalAcceptances.get(goalId);
4188
+ if (prior !== undefined) {
4189
+ if (prior.fingerprint !== fingerprint)
4190
+ throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" was accepted under a different submission; one goalId never carries two specs (SPEC 13.6)`);
4191
+ return prior;
4192
+ }
4193
+ let resolveAccept;
4194
+ let rejectAccept;
4195
+ const acceptP = new Promise((res, rej) => { resolveAccept = res; rejectAccept = rej; });
4196
+ let acceptance;
4197
+ // H1: set the instant the terminal path is ENTERED, not when it succeeds — the post-accept
4198
+ // fallback below must fire only when `onOutcome` never ran at all, never as a second attempt
4199
+ // behind a commit that threw.
4200
+ let terminalEntered = false;
4201
+ // TWO DIFFERENT QUESTIONS, DELIBERATELY NOT ONE FLAG.
4202
+ //
4203
+ // `terminalEntered` HAS this goal already been settled (or claimed) by someone?
4204
+ // `ownsGoal` MAY THIS ATTEMPT settle it at all?
4205
+ //
4206
+ // The second is the authority, and it is default-CLOSED: an attempt earns it by WINNING the
4207
+ // create-only `bindGoal` CAS below, and nothing else grants it. That matters because the
4208
+ // post-accept fallback exists to settle a goal nobody answered, so every new way of leaving
4209
+ // this function is opted INTO committing a terminal unless something stops it. Deriving the
4210
+ // right from the claim instead of from a running record means a losing attempt CANNOT commit
4211
+ // down any unwind path, including ones added later that never thought about this. Collapsing
4212
+ // the two into one boolean is what let a duplicate-goal loser steal the winner's terminal
4213
+ // (#357): it had entered no terminal, so the fallback wrote one for it, using its own abort
4214
+ // message as the caller-visible outcome.
4215
+ let ownsGoal = false;
4216
+ // The terminal commits OFF-handler on the goal-writer connection (manager-only authority; a
4217
+ // caller cannot publish it). TWO COMPOSED FENCES (defense in depth): must-5 (a) reads THIS
4218
+ // incarnation's OWN gate epoch and REFUSES a superseded commit (the currency belt), and (b)
4219
+ // barrier-revoke evicts this connection on takeover. The terminal lands on the ONE subject
4220
+ // SPEC:1394 reserves; first-terminal-fact-wins is global, so a committed outcome is visible
4221
+ // to every reader in every incarnation. On success the reconcile-index entry is cleared.
4222
+ //
4223
+ // Named rather than inlined into the hooks below so the H1 post-accept fallback drives THIS
4224
+ // path — one commit site, so the progress event, the index clear and the `agentGoals` cleanup
4225
+ // cannot drift between a normal outcome and a recovered one.
4226
+ const onOutcome = async (o) => {
4227
+ // THE AUTHORITY CHECK, and the only one. An attempt that never won the bind provisioned
4228
+ // nothing and has no outcome to report: the goal belongs to whoever won it, in this
4229
+ // incarnation or a sibling. Refusing here rather than at each unwind site is the point --
4230
+ // there is exactly one commit path, so this fences every route into it, present and future.
4231
+ if (!ownsGoal)
4232
+ return;
4233
+ terminalEntered = true; // entered, not succeeded — see the catch below
4234
+ try {
4235
+ await this.assertGoalWriterEpochCurrent(epoch); // must-5 (a): a superseded corpse never commits
4236
+ let fact;
4237
+ if (o.kind === "succeeded") {
4238
+ this.emitGoalProgress(ref, epoch, { phase: "presence" });
4239
+ ({ fact } = await commitGoalResult(gw.ctx, { ref, now: Date.now(), cause: "complete", state: "succeeded", data: o.data, committer: { instanceId: this.managerInstanceId, epoch } }));
4240
+ }
4241
+ else if (o.kind === "failed") {
4242
+ ({ fact } = await commitGoalResult(gw.ctx, { ref, now: Date.now(), cause: "complete", state: "failed", data: o.data, committer: { instanceId: this.managerInstanceId, epoch } }));
4243
+ }
4244
+ else {
4245
+ ({ fact } = await settleGoalUncertain(gw.ctx, { ref, now: Date.now(), committer: { instanceId: this.managerInstanceId, epoch } }));
4246
+ }
4247
+ this.emitGoalProgress(ref, epoch, { phase: "terminal", state: fact.state, ...(fact.data !== undefined ? { data: fact.data } : {}) });
4248
+ await clearGoalIndex(gw.ctx, ref); // must-5 Q-B: terminal reached - the successor never reconciles it
4249
+ if (acceptance)
4250
+ this.agentGoals.delete(acceptance.name); // goal terminal - no cancel path left
4251
+ }
4252
+ catch (e) {
4253
+ // THE NARROWER LEG, LEFT OPEN DELIBERATELY. If the COMMIT ITSELF throws (the currency belt
4254
+ // refused a superseded commit, or the broker failed) there is genuinely no terminal, and the
4255
+ // H1 fallback must NOT retry it: a retry either loses the same way or overwrites a real
4256
+ // supersession refusal with a manufactured outcome. That is why `terminalEntered` is set on
4257
+ // ENTRY. This leg is infrastructure-class and converges through the reconcile index at the
4258
+ // next boot, which is why the index is NOT cleared above on this path. Do not "close" it
4259
+ // here with a retry loop.
4260
+ console.error(`! goal terminal commit for ${goalId} failed: ${e.message}`);
4261
+ }
4262
+ };
4263
+ const bg = run({
4264
+ onAccepted: async ({ name, agentTriple }) => {
4265
+ // must-5 Q-B: record the goal in the reconcile index BEFORE the bind (index-CAS-before-bind),
4266
+ // so a successor incarnation finds + settles this goal if we crash before its terminal. A
4267
+ // crash between this write and the bind leaves an index entry whose goal status is absent —
4268
+ // the sweep clears it as a no-goal; a crash before it leaves no entry (never durable). A
4269
+ // Carry THIS incarnation's instanceId (the executor coord) so a multi-instance sweep can skip a live sibling's goal.
4270
+ //
4271
+ // H2, THE ACCEPTANCE FLOOR: the allocated identity is written HERE, before the bind and so
4272
+ // before the acceptance is acked, because this entry is the only durable record of it that
4273
+ // exists that early — the goal spec does not carry it and the terminal does not exist yet.
4274
+ // A same-goalId attempt that loses the bind while the winner is still in flight can then
4275
+ // serve what the winner actually allocated instead of inventing an empty identity.
4276
+ const idx = await recordGoalIndex(gw.ctx, ref, executor.lifecycleUid, { name, actor: agentTriple.actor, uid: agentTriple.uid });
4277
+ // A create loss is an idempotent retry ONLY for the same incarnation. A FOREIGN iid means a
4278
+ // sibling instance accepted this goalId (the live vector is a client retry over ANYCAST, not
4279
+ // a journal consumer): this attempt provisions nothing and answers with the winner's floor,
4280
+ // or refuses if that instance never persisted one.
4281
+ if (!idx.recorded && idx.existing.iid !== executor.lifecycleUid) {
4282
+ acceptance = this.acceptanceFromIndex(idx.existing, goalId, fingerprint, executor);
4283
+ resolveAccept(acceptance);
4284
+ // No claim needed here: `ownsGoal` is still false, so the commit path refuses this attempt.
4285
+ throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" was accepted by instance "${idx.existing.iid}"; that instance's acceptance is served and this attempt provisions nothing (SPEC 13.6)`);
4286
+ }
4287
+ // Bind AFTER the accept-path checks (M6/capacity/persona) + identity mint, BEFORE any provision:
4288
+ // a create-only CAS per goalId (pin 1 — a refused accept above left zero bind, zero reserve).
4289
+ const b = await bindGoal(gw.ctx, ref, fingerprint);
4290
+ if (!b.bound) {
4291
+ if (b.existing.fingerprint !== fingerprint)
4292
+ throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" is already bound to a different submission; one goalId never carries two specs (SPEC 13.6)`);
4293
+ // Lost a concurrent same-goalId race: serve the winner's acceptance and abort THIS provision
4294
+ // (no second spawn). The throw unwinds to the finally, which releases this attempt's reserve.
4295
+ acceptance = this.goalAcceptances.get(goalId) ?? await this.cachedSpawnAcceptance(ref, goalId, fingerprint, executor);
4296
+ resolveAccept(acceptance);
4297
+ // No claim needed here either. This attempt lost the CAS, so `ownsGoal` is still false and
4298
+ // the single check in `onOutcome` refuses it. Before that check existed this site had to
4299
+ // remember to claim the terminal by hand, and the one place that forgot is how a loser
4300
+ // came to commit `failed` on the winner's goal, using its own abort text as the outcome.
4301
+ // Unwind the provision (the acceptance is already served); the code is discarded by the
4302
+ // caller (acceptance !== undefined), so it just aborts this attempt's side-effects.
4303
+ throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" is already accepted; the cached acceptance is served`);
4304
+ }
4305
+ // WON the claim: this attempt, and only this attempt, may settle this goal.
4306
+ ownsGoal = true;
4307
+ await createGoal(gw.ctx, ref, {
4308
+ fingerprint,
4309
+ command: ctx.subject.command,
4310
+ caller: { id: `${ctx.subject.caller.owner}.${ctx.subject.caller.actor}`, lifecycleUid: ctx.subject.caller.uid },
4311
+ // The ACCEPTING incarnation's epoch: half of the terminal's attribution pair (§13.6).
4312
+ acceptedEpoch: epoch,
4313
+ requestId: goalId,
4314
+ sourceSeq: 0,
4315
+ acceptedAt,
4316
+ readinessDeadlineMs: this.readinessTimeoutMs,
4317
+ });
4318
+ acceptance = { name, owner: agentTriple.owner, actor: agentTriple.actor, uid: agentTriple.uid, goalId, fingerprint, executor };
4319
+ this.goalAcceptances.set(goalId, acceptance);
4320
+ this.agentGoals.set(name, ref); // M4: a despawn of this name mid-goal drives the cancel path
4321
+ resolveAccept(acceptance);
4322
+ this.emitGoalProgress(ref, epoch, { phase: "handoff" });
4323
+ },
4324
+ onLaunched: () => this.emitGoalProgress(ref, epoch, { phase: "launched" }),
4325
+ onOutcome,
4326
+ // Claim the terminal WITHOUT committing one: the despawn/stop that ended this launch owns
4327
+ // it and commits `cancel`. This only stops the non-ok reply below from manufacturing a
4328
+ // `failed` that would race that `cancel` (first-terminal-fact-wins).
4329
+ onTerminalDeferred: () => { terminalEntered = true; },
4330
+ });
4331
+ bg.then((reply) => {
4332
+ // Refused BEFORE onAccepted (M6 hard-pin collision, capacity, persona-not-found) — no goal bound.
4333
+ if (acceptance === undefined) {
4334
+ rejectAccept(new EpEnvelopeError("failed-precondition", reply.error ?? "spawn refused at accept"));
4335
+ return;
4336
+ }
4337
+ // H1: `run` CATCHES its own body (a throw in buildLaunch/runtime.spawn) and RESOLVES `{ok:false}`
4338
+ // rather than rejecting, so a post-accept failure arrives here, not in the catch below, and
4339
+ // reaches none of the onOutcome sites. Without this the goal stays accepted-but-unanswered:
4340
+ // the caller follows epe to a terminal that never comes, and the reconcile index that would
4341
+ // settle it is only swept at BOOT, so a manager that stays up never converges it.
4342
+ if (reply.ok === false && !terminalEntered)
4343
+ return onOutcome({ kind: "failed", data: { error: reply.error ?? "spawn failed after accept" } });
4344
+ }).catch((e) => {
4345
+ if (acceptance === undefined) {
4346
+ rejectAccept(e);
4347
+ return;
4348
+ }
4349
+ // Same obligation for a genuine rejection (one that escaped `run`'s own catch).
4350
+ if (!terminalEntered)
4351
+ return onOutcome({ kind: "failed", data: { error: e?.message ?? String(e) } });
4352
+ console.error(`! spawn-as-action async body for ${goalId}: ${e?.message ?? String(e)}`);
4353
+ }).catch((e) => console.error(`! goal terminal fallback for ${goalId}: ${e?.message ?? String(e)}`));
4354
+ return acceptP;
4355
+ }
4356
+ /** H2: an acceptance served from a WINNER'S durable acceptance floor (the goal-index entry it
4357
+ * wrote before its own ack). Refuses `unavailable` rather than inventing one — see
4358
+ * {@link cachedSpawnAcceptance} for why an empty identity is never an acceptable answer. */
4359
+ acceptanceFromIndex(entry, goalId, fingerprint, executor) {
4360
+ if (entry.allocated === undefined)
4361
+ throw new EpEnvelopeError("unavailable", `goal "${goalId}" was accepted by instance "${entry.iid}", which persisted no acceptance floor; its allocated identity is not readable from here (SPEC 13.6)`);
4362
+ return { name: entry.allocated.name, owner: DEV_OWNER, actor: entry.allocated.actor, uid: entry.allocated.uid, goalId, fingerprint, executor };
4363
+ }
4364
+ /** Reconstruct a cached acceptance for a same-goalId retry NOT in the live map (a prior incarnation
4365
+ * accepted it, or a concurrent winner whose map write this reader has not yet observed).
4366
+ *
4367
+ * H2 — WHY THIS PREFERS THE INDEX OVER THE TERMINAL. It used to read only the committed terminal
4368
+ * and fall back to `{name:"", actor:"", uid:""}` when there was none. That is the common case,
4369
+ * not a corner: a client retry over ANYCAST reaches a sibling while the winner is still
4370
+ * provisioning, so no terminal exists yet, and the caller was handed an ACCEPTED reply naming an
4371
+ * empty agent it can never address. The acceptance floor in the goal index exists from the moment
4372
+ * of acceptance, so it answers precisely the window the terminal cannot. Where neither is
4373
+ * readable the honest answer is a REFUSAL: an accepted goal whose identity nobody can name is
4374
+ * `unavailable`, never a hollow success. */
4375
+ async cachedSpawnAcceptance(ref, goalId, fingerprint, executor) {
4376
+ const entry = await readGoalIndex(this.goalWriter.ctx, ref);
4377
+ if (entry?.allocated !== undefined)
4378
+ return this.acceptanceFromIndex(entry, goalId, fingerprint, executor);
4379
+ // The index is CLEARED at terminal, so a settled goal legitimately has no entry: fall back to
4380
+ // the terminal's data, which carries the same identity for exactly that case.
4381
+ const result = await readGoalResult(this.goalWriter.ctx, ref);
4382
+ const d = (result?.data ?? {});
4383
+ if (typeof d.name === "string" && d.name.length > 0 && typeof d.id === "string" && d.id.length > 0 && typeof d.lifecycleUid === "string" && d.lifecycleUid.length > 0)
4384
+ return { name: d.name, owner: DEV_OWNER, actor: d.id, uid: d.lifecycleUid, goalId, fingerprint, executor };
4385
+ throw new EpEnvelopeError("unavailable", `goal "${goalId}" is already accepted but its allocated identity is not readable (no acceptance floor, and no terminal carrying one); retry (SPEC 13.6)`);
4386
+ }
4387
+ /** M4 (settle race): a despawn MID-GOAL drives the goal's cancel terminal - transition to
4388
+ * `cancelling`, then commit the `cancel` cause on the goal-writer connection. First-terminal-fact
4389
+ * wins: if the readiness outcome already committed (succeeded/failed/uncertain) the transition or
4390
+ * the create-only commit loses gracefully and the readiness terminal stands. Fire-and-forget from
4391
+ * despawn (the process teardown is authoritative for the agent; this settles the GOAL honestly).
4392
+ * Cancel rides the despawn's own authorizeNamed reach (pin 5) - there is no cancel-by-goalId. */
4393
+ async cancelAgentGoal(name, mode) {
4394
+ const gw = this.goalWriter;
4395
+ const ref = this.agentGoals.get(name);
4396
+ if (!gw || !ref)
4397
+ return;
4398
+ this.agentGoals.delete(name);
4399
+ const epoch = this.serviceServe?.grant.epoch ?? 0;
4400
+ try {
4401
+ await this.assertGoalWriterEpochCurrent(epoch); // must-5 (a): a superseded corpse never commits a cancel terminal either
4402
+ await transitionGoal(gw.ctx, ref, "cancelling", { fields: { cancelMode: mode } });
4403
+ const r = await commitGoalResult(gw.ctx, { ref, now: Date.now(), cause: "cancel", data: { cancelledBy: "despawn" }, committer: { instanceId: this.managerInstanceId, epoch } });
4404
+ this.emitGoalProgress(ref, epoch, { phase: "terminal", state: r.fact.state, ...(r.fact.data !== undefined ? { data: r.fact.data } : {}) });
4405
+ await clearGoalIndex(gw.ctx, ref); // must-5 Q-B: terminal reached - the successor never reconciles it
4406
+ }
4407
+ catch {
4408
+ // the goal already terminalized (the readiness outcome won the settle race) - nothing to cancel.
4409
+ }
4410
+ }
4411
+ /** The static F1 terminal for one departed incarnation (Unit B): delegates the gate/head CAS
4412
+ * sequence to the shared core saga over the executor transport; the footprint teardown (creds
4413
+ * file + broker durables/ACL) runs INSIDE the barrier as its cleanup step. On completion the
4414
+ * wire principal joins {@link retiredPrincipals} (the F5 refusal index) and the name hold
4415
+ * clears (ABA-guarded by uid). A PRE-UNIT-B lifecycle (no slot row — spawned before the
4416
+ * durable registry existed) has nothing to terminalize: its footprint teardown runs directly
4417
+ * and the hold clears, the honest upgrade path. */
4418
+ async driveStaticRetirement(a) {
4419
+ const opId = retireOpId(a.lifecycleUid);
4420
+ const cleanup = async () => {
4421
+ const secrets = this.secrets;
4422
+ const files = a.secretPaths ?? agentLifecycleSecretFilePaths(this.workspaceRoot, a.name, a.lifecycleUid);
4423
+ if (files.creds) {
4424
+ await secrets.delete(agentSecretKeyForFile(files.creds));
4425
+ rmSync(files.creds, { force: true });
4426
+ }
4427
+ await this.deprovisionBroker(a);
4428
+ };
4429
+ try {
4430
+ await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: a.id, lifecycleUid: a.lifecycleUid, alias: a.name }, async (t) => {
4431
+ const slot = await readStaticSlot(t, DEV_OWNER, a.name);
4432
+ if (slot === undefined || slot.row.lifecycleUid !== a.lifecycleUid) {
4433
+ // No durable registration for THIS incarnation: a pre-Unit-B spawn (or a slot already
4434
+ // replaced by a successor — then this stale teardown must not touch the registry at all).
4435
+ await cleanup();
4436
+ return;
4437
+ }
4438
+ await runStaticTerminal(t, { owner: DEV_OWNER, alias: a.name, actor: a.id, lifecycleUid: a.lifecycleUid, opId }, { cleanup, log: (line) => console.error(`static retirement ${a.name}: ${line}`) });
4439
+ });
4440
+ this.retiredPrincipals.add(principalKey(DEV_OWNER, a.id).key);
4441
+ const cur = this.retiring.get(a.name);
4442
+ if (cur && cur.lifecycleUid === a.lifecycleUid)
4443
+ this.retiring.delete(a.name); // ABA-guarded hold clear
4444
+ }
4445
+ catch (e) {
4446
+ const h = this.retiring.get(a.name);
4447
+ if (h && h.lifecycleUid === a.lifecycleUid)
4448
+ h.lastError = `the static retirement did not complete (${e.message}); the name stays held - a same-name spawn retries the same terminal (op ${opId})`;
4449
+ console.error(`static retirement ${a.name} (${a.id}): ${e.message}`);
4450
+ }
4451
+ }
4452
+ /** F5(b) push renewal of ONE live managed-static credential (Unit B): re-mint the SAME nkey
4453
+ * identity with the SAME scope (recorded on the managed row at spawn) and a fresh bounded
4454
+ * exp, ledger the new credentialId (slot record first, then the row, then the file — a
4455
+ * credential is never materialized before its ledger row exists), and re-sign the SAME
4456
+ * lifecycle-keyed file the agent endpoint's source seam re-reads. Never advances the epoch,
4457
+ * never routes through any barrier (renewal is the THIRD transition). */
4458
+ async renewManagedStaticCred(a) {
4459
+ if (!this.auth || !a.seed || !a.secretPaths?.creds)
4460
+ throw new Error("renewManagedStaticCred: not a renewable managed-static agent");
4461
+ // THIS CHECK IS THE AUTHORITATIVE ONE. The renewal sweep's own `a.terminalizing` filter is an
4462
+ // optimisation that has already-awaited by the time it matters; removing or weakening this line
4463
+ // promotes that filter into the whole guard, with nothing failing at the moment of the change.
4464
+ //
4465
+ // CONFIRMED, OPEN, AND UNGATED. This check runs at ENTRY and there are FOUR awaits before the
4466
+ // two writes below (`secrets.put` and `materializeSecretToFile`). A despawn landing in that
4467
+ // window latches `terminalizing` and the retirement cleanup deletes exactly those two things —
4468
+ // same secret key, same path — so an in-flight renewal RE-CREATES a valid bounded credential
4469
+ // after teardown removed it, and `appendStaticCredentialRow` lands in the window too, which is
4470
+ // the worse half: a stale file is recoverable by re-running cleanup, a durable credential row
4471
+ // is the journal asserting the credential is legitimate.
4472
+ //
4473
+ // Reproduced by `smoke:renewal-terminal-race` (`renewal-terminal-race.smoke.ts`), which asserts
4474
+ // the DURABLE ROW rather than the file — the file is timing-dependent, the row is a KV read.
4475
+ // That suite is deliberately NOT in `smoke:ci`: it is expected RED until this is fixed, and
4476
+ // gating a known red trains readers to treat the gate as noisy. So the absence of a red here
4477
+ // is not evidence this is closed; run that suite.
4478
+ //
4479
+ // Reproduced on the FIRST attempt that reached the race, and that suite cannot produce a second:
4480
+ // the alias frees only when teardown completes, and the defect is that teardown does not, so
4481
+ // every later attempt is refused at spawn. That is a limit of the probe, NOT of the world — a
4482
+ // FRESH ALIAS PER ATTEMPT makes a rate measurable. Do not read hits-over-attempts off that file
4483
+ // as written; it is a number that is not a count.
4484
+ //
4485
+ // The fix is to make the WRITES conditional on the same latch the teardown orders against,
4486
+ // never to retry: the correct outcome is "no credential", never "a credential minted later".
4487
+ if (a.terminalizing)
4488
+ throw new Error("renewManagedStaticCred: the lifecycle is terminalizing; no credential is minted after the terminal begins");
4489
+ const exp = Math.floor(Date.now() / 1000) + MANAGED_STATIC_TTL_SEC;
4490
+ // The SAME permission scope the spawn minted (recorded on the managed row): allowSubscribe/
4491
+ // allowPublish/role/capabilities are the JWT-shaping inputs; `subscribe` (the active read
4492
+ // set) shapes durable membership only and is not a mint input.
4493
+ const creds = await mintCreds(this.auth, { id: a.id, seed: a.seed }, "agent", {
4494
+ allowSubscribe: a.launch.allowSubscribe,
4495
+ allowPublish: a.launch.allowPublish,
4496
+ role: a.role,
4497
+ capabilities: a.launch.capabilities,
4498
+ lifecycleUid: a.lifecycleUid,
4499
+ expiresAt: exp,
4500
+ });
4501
+ const credentialId = rawDigest(creds).replace("sha256:", "sha256-");
4502
+ await this.withLifecycleExecutor({ owner: DEV_OWNER, actor: a.id, lifecycleUid: a.lifecycleUid, alias: a.name }, async (t) => {
4503
+ await recordSlotCredential(t, DEV_OWNER, a.name, a.lifecycleUid, credentialId);
4504
+ await appendStaticCredentialRow(t, { lifecycleUid: a.lifecycleUid, credentialId, holderPrincipal: principalKey(DEV_OWNER, a.id).key, exp });
4505
+ });
4506
+ const secrets = this.secrets;
4507
+ await secrets.put(agentSecretKeyForFile(a.secretPaths.creds), creds);
4508
+ await materializeSecretToFile(secrets, agentSecretKeyForFile(a.secretPaths.creds), a.secretPaths.creds);
4509
+ console.error(`managed cred renewal ${a.name}: re-signed for the same identity (exp +${MANAGED_STATIC_TTL_SEC}s); the agent endpoint's source re-read adopts it`);
4510
+ }
4511
+ /** The Unit B reconciliation (F3 "no active orphan"): ensure the authority stores, then sweep
4512
+ * every durable slot row and act by the TOTAL resume table — `provisioning`/`terminalizing`
4513
+ * re-drive the exact-op terminal; an `active` row survives ONLY when a LIVE managed agent this
4514
+ * process owns backs it at the same uid (`adopted`), else its process is gone and it
4515
+ * terminalizes; `retired` rows seed the F5 refusal index. Two call sites: the BOOT sweep
4516
+ * (`postAdoption=false`, under the lease before control serving) DEFERS active-non-adopted
4517
+ * slots while a resume is still pending (adoption runs after it); the POST-ADOPTION sweep
4518
+ * (`postAdoption=true`, inside finalizeResume while `resumeRequired` still fences ordinary
4519
+ * spawns) terminalizes any active slot the resume did not claim. */
4520
+ async reconcileStaticLifecycles(postAdoption = false) {
4521
+ if (!this.auth)
4522
+ return;
4523
+ const identity = newIdentity();
4524
+ const creds = await mintCreds(this.auth, identity, "provisioner");
4525
+ const nc = await connect({ servers: this.servers ?? DEFAULT_SERVER, ...standaloneConnectOpts({ creds, /* not yet wired to a recorded transport */ tls: false }), maxReconnectAttempts: 0 });
4526
+ const slotRows = [];
4527
+ try {
4528
+ const jsm = await jetstreamManager(nc);
4529
+ const kvm = new Kvm(nc);
4530
+ await ensureAuthorityStores(jsm, kvm, this.space);
4531
+ const recordsKv = await kvm.open(recordsBucket(this.space));
4532
+ const t = staticLifecycleTransport(recordsKv, recordsKv /* auth reads unused in the sweep */);
4533
+ const keys = await recordsKv.keys(`${STATIC_SLOT_PREFIX}.${DEV_OWNER}.>`);
4534
+ const aliases = [];
4535
+ for await (const k of keys)
4536
+ aliases.push(k.split(".").slice(2).join("."));
4537
+ for (const alias of aliases) {
4538
+ const slot = await readStaticSlot(t, DEV_OWNER, alias);
4539
+ if (slot !== undefined)
4540
+ slotRows.push(slot.row);
4541
+ }
4542
+ }
4543
+ finally {
4544
+ await nc.drain().catch(() => nc.close());
4545
+ }
4546
+ for (const row of slotRows) {
4547
+ if (row.phase === "retired") {
4548
+ // A retirement is a GLOBAL refusal fact — seed the F5 index for EVERY retired incarnation
4549
+ // regardless of which instance owned it, so a sibling-retired incarnation's copied credential
4550
+ // is refused at this control surface too. Ownership gates only the DESTRUCTIVE sweep below.
4551
+ this.retiredPrincipals.add(principalKey(row.owner, row.actor).key);
4552
+ continue;
4553
+ }
4554
+ // 3b-2 RECONCILE OWNERSHIP (multi-manager-per-space): a manager adjudicates ONLY the non-retired
4555
+ // rows THIS logical instance owns. A SIBLING manager's active/provisioning row is LEFT UNTOUCHED —
4556
+ // sweep-terminalizing it would destroy the sibling's live agent (the historical all-agents-kill
4557
+ // hazard, now cross-instance). A legacy row (pre-3b-2, no owner recorded) predates multi-manager,
4558
+ // so this manager is its legitimate single-manager-past successor and reconciles it. An orphaned
4559
+ // sibling row is reclaimed only by an explicit operator CAS takeover (ruling 1), never here.
4560
+ if (row.ownerInstanceId !== undefined && row.ownerInstanceId !== this.managerInstanceId)
4561
+ continue;
4562
+ // ADOPTION is genuine membership: a slot backed by a live managed agent THIS process owns
4563
+ // at the SAME uid is never an orphan (empty at boot; exactly the adopted set at the
4564
+ // post-adoption sweep — the fix for the F3 resume hole).
4565
+ const live = this.agents.get(row.alias);
4566
+ const adopted = live !== undefined && live.lifecycleUid === row.lifecycleUid;
4567
+ // Boot sweep with a resume PENDING: an active slot may yet be adopted (the resume path runs
4568
+ // AFTER this boot sweep), so DEFER it — the post-adoption sweep terminalizes any the resume
4569
+ // did not claim. provisioning/terminalizing NEVER defer (they are crashed operations, never
4570
+ // an agent to adopt). At `postAdoption` (or a non-resume boot) nothing defers.
4571
+ if (!postAdoption && row.phase === "active" && !adopted && this.resumeRequired)
4572
+ continue;
4573
+ const action = planStaticSlotResume(row, adopted);
4574
+ if (action === "none")
4575
+ continue;
4576
+ console.error(`static reconcile ${row.alias}: slot is ${row.phase} with no live managed owner${postAdoption ? " after resume adoption" : ""} - driving its exact-op terminal (uid ${row.lifecycleUid})`);
4577
+ await this.driveStaticRetirement({ id: row.actor, name: row.alias, lifecycleUid: row.lifecycleUid });
4578
+ }
4579
+ }
4580
+ /** The F5(a) membership gate (Unit B, the F5-bind design): decide a control caller by its
4581
+ * AUTHENTICATED wire principal. A LIVE managed slot passes (unless terminalizing); a RETIRING
4582
+ * hold or a RETIRED static incarnation refuses even with a tier-valid JWT (the
4583
+ * copied-credential vector — its subject can never collide with a successor's, so this match
4584
+ * is non-forgeable); any OTHER principal is not a managed lifecycle (an operator instrument:
4585
+ * the credential tier governs, exactly as before). Never name alone, never a payload field. */
4586
+ lifecycleMembershipRefusal(caller) {
4587
+ for (const a of this.agents.values()) {
4588
+ if (this.managedPrincipal(a) === caller)
4589
+ return a.terminalizing
4590
+ ? `the caller's lifecycle ${a.lifecycleUid} is terminalizing; control is refused from the first terminal step (F5)`
4591
+ : undefined;
4592
+ }
4593
+ for (const [name, hold] of this.retiring) {
4594
+ const held = hold.userOwner ? hold.agentId : principalKey(DEV_OWNER, hold.agentId).key;
4595
+ if (held === caller)
4596
+ return `the caller's lifecycle ${hold.lifecycleUid} (name "${name}") is retiring; a retiring incarnation's credential holds no control authority (F5)`;
4597
+ }
4598
+ if (this.retiredPrincipals.has(caller))
4599
+ return "the caller's lifecycle is retired; a retired incarnation's credential holds no control authority (F5)";
4600
+ return undefined;
4601
+ }
2728
4602
  async withProvisioner(fn) {
2729
4603
  if (!this.auth)
2730
4604
  throw new Error("withProvisioner: no space auth (an open mesh has no scoped creds)");
@@ -2821,33 +4695,92 @@ export class Manager {
2821
4695
  }
2822
4696
  return { ok: true, data: { name, path } };
2823
4697
  }
2824
- async opAttach(args, caller, admin) {
2825
- const name = String(args.name ?? "").trim();
2826
- const a = this.agents.get(name);
2827
- if (!a)
2828
- return { ok: false, error: `no agent "${name}"` };
2829
- // attach grants terminal read+write — same scoping as despawn: own child (and, on a user
2830
- // mesh, the caller's owner-domain) on the privileged tier, any agent on admin.
2831
- const denied = await this.authorizeNamed(a, caller, admin);
2832
- if (denied)
2833
- return { ok: false, error: denied };
4698
+ /** The post-authorization attach effect (P2 item 6): mint the holder-bound §13.6 offer, redeem it
4699
+ * through the ONE session plane (one-use CAS + presenter-equality), and stand up the PTY bridge —
4700
+ * atomically. The reply is the SIGNED grant (no ws:// URL, non-bearer, never logged); the caller
4701
+ * redeems it over the mesh with a per-session rails-only cred it mints itself. Only streamable
4702
+ * backends (pty/host) attach; an external runtime's attach() throws with per-runtime guidance. */
4703
+ async attachAuthorized(a, caller) {
4704
+ if (!this.sessionPlane)
4705
+ return { ok: false, error: "the manager session plane is not available (the manager is not fully started)" };
2834
4706
  // A name is a reusable slot and the authorization above can await (user mode reads the ledger).
2835
- // If the slot was stopped and refilled while we waited, everything below would act on a
2836
- // successor this caller was never authorized for so re-assert the incarnation, then keep
2837
- // working from the handle we authorized rather than re-resolving the name.
2838
- if (this.agents.get(name) !== a)
2839
- return { ok: false, error: `agent "${name}" was replaced during authorization - retry` };
2840
- // Only pty streams over the WS attach endpoint. External runtimes are watched natively,
2841
- // and each handle's attach() throws with the right per-runtime guidance.
2842
- if (a.handle.kind !== "pty") {
2843
- try {
2844
- a.handle.attach();
2845
- }
2846
- catch (e) {
2847
- return { ok: false, error: e.message };
2848
- }
4707
+ // The ep target is incarnation-pinned, so `a` cannot BE a successor but the slot it names can
4708
+ // have been stopped and refilled while we waited, and everything below must act on the
4709
+ // incarnation this caller was actually authorized for, never on whoever holds the name now.
4710
+ if (this.agents.get(a.name) !== a)
4711
+ return { ok: false, error: `agent "${a.name}" was replaced during authorization - retry` };
4712
+ // Never establish a session over a dead agent a doomed session (the caller would get an
4713
+ // immediate process-exit at best, a confusing empty terminal at worst). Refuse honestly.
4714
+ if (a.handle.status() !== "running")
4715
+ return { ok: false, error: `agent "${a.name}" is not running (${a.handle.status()}); nothing to attach` };
4716
+ // CLAIM the session slot BEFORE attaching the target's PTY, matching the console door: this door
4717
+ // used to attach first and check capacity inside establishAttach, so a `resource-exhausted`
4718
+ // refusal landed AFTER the attach. The claim is carried INTO establishAttach, so one reservation
4719
+ // spans the attach and the establishment, and it is released on every refusal below.
4720
+ //
4721
+ // RESIDUAL, NAMED: no shipped runtime acquires anything at attach time — pty's `attach()` returns
4722
+ // a pure view (it registers a data subscriber only when `onData` is called) and tmux/cmux/orca
4723
+ // throw — so an attach nobody bridges is a garbage-collectible object, not a held resource, and
4724
+ // ordering alone suffices. A future runtime that DOES acquire something in `attach()` reopens
4725
+ // this: it would need a release on the failure paths, and `AttachSession` has no close today.
4726
+ const slot = this.sessionPlane.claimSlot();
4727
+ let session;
4728
+ try {
4729
+ session = a.handle.attach();
4730
+ }
4731
+ catch (e) {
4732
+ slot.release();
4733
+ return { ok: false, error: e.message };
4734
+ }
4735
+ // establishAttach releases the claim it was handed on every exit; nothing to unwind here.
4736
+ const { grant } = await this.sessionPlane.establishAttach(caller, { name: a.name, lifecycleUid: a.lifecycleUid }, session, slot);
4737
+ return { ok: true, data: { grant } };
4738
+ }
4739
+ /** P2 item 6: the console's mesh §13.6 session establisher (backing `POST /session/<name>` on the
4740
+ * loopback face). Drives THE ONE plane — same establishAttach as the ep `attach` command — with
4741
+ * the loopback OPERATOR as holder (same-host trust boundary), then hands the browser everything it
4742
+ * needs to open the caller rail over the broker ws listener: the holder-bound grant, a per-session
4743
+ * RAILS-ONLY caller cred (static mints from the seed, TTL-bound to the session; an open mesh has no
4744
+ * credential system so the browser connects bare), and the ws URL. NO 127.0.0.1 terminal transport
4745
+ * — the terminal rides the mesh session. Injected only when a wsPort exists (see the constructor). */
4746
+ async establishConsoleSession(name) {
4747
+ if (!this.sessionPlane)
4748
+ throw new Error("the manager session plane is not available (the manager is not fully started)");
4749
+ if (this.wsPort === undefined)
4750
+ throw new Error("the broker websocket port is not configured; the console cannot open a mesh session");
4751
+ // CLAIM the session slot here, before the PTY attach and before this establisher goes on to
4752
+ // mint a seed-signed `session-caller` credential: a capacity refusal must land before anything
4753
+ // with a side effect or a cost. The claim is carried INTO establishAttach, so the reservation
4754
+ // spans the whole establishment rather than being a check that a concurrent caller can race.
4755
+ // (See attachAuthorized for why the attach itself needs no unwind on any shipped runtime.)
4756
+ const slot = this.sessionPlane.claimSlot();
4757
+ try {
4758
+ const a = this.agents.get(name);
4759
+ if (!a)
4760
+ throw new Error(`no managed agent "${name}"`);
4761
+ if (a.handle.status() !== "running")
4762
+ throw new Error(`agent "${name}" is not running (${a.handle.status()}); nothing to attach`);
4763
+ const session = a.handle.attach(); // throws for non-streamable runtimes — surfaced to the browser as a 500
4764
+ // The loopback operator is the console's holder (same-host trust boundary).
4765
+ const caller = { owner: DEV_OWNER, actor: "console", uid: this.managerLifecycleUid };
4766
+ const { grant } = await this.sessionPlane.establishAttach(caller, { name: a.name, lifecycleUid: a.lifecycleUid }, session, slot);
4767
+ // The caller credential is minted ONLY after a session is really live, so a refused or failed
4768
+ // establishment never yields a seed-signed JWT. (A failure HERE would leave a live session the
4769
+ // browser never reaches, holding its slot until the grant expires — unreachable in practice,
4770
+ // because the SERVING mint above signs from this same `this.auth` first and would have failed
4771
+ // before any session existed.)
4772
+ const creds = this.auth
4773
+ ? await mintCreds(this.auth, newIdentity(), "session-caller", {
4774
+ sessionCaller: { endpoint: MANAGER_ENDPOINT, sessionId: grant.sessionId, epoch: grant.serving.epoch },
4775
+ expiresAt: Math.floor(grant.exp / 1000), // grant.exp is ms (now+ttlMs); the JWT exp is seconds
4776
+ })
4777
+ : "";
4778
+ return { grant, wsUrl: `ws://127.0.0.1:${this.wsPort}`, creds };
4779
+ }
4780
+ catch (e) {
4781
+ slot.release(); // idempotent with establishAttach's own release
4782
+ throw e;
2849
4783
  }
2850
- return { ok: true, data: { ws: this.attach.url(name, a.handle) } };
2851
4784
  }
2852
4785
  /** Managed agents cross-referenced with live presence (the manager sees the roster). */
2853
4786
  /** `ownerFilter`: restrict to agents whose spawn-time stored `userOwner` equals it (the ps/status
@@ -2860,7 +4793,7 @@ export class Manager {
2860
4793
  // FAIL-CLOSED: a failed record is the failure + repair sentence; a missing/malformed or
2861
4794
  // stale record on a live agent is auth-unknown/auth-stale, NEVER silently healthy.
2862
4795
  const health = a.userOwner
2863
- ? agentAuthState(agentSecretFilePaths(this.workspaceRoot, a.name).health)
4796
+ ? agentAuthState(a.secretPaths?.health ?? agentLifecycleSecretFilePaths(this.workspaceRoot, a.name, a.lifecycleUid).health)
2864
4797
  : undefined;
2865
4798
  return {
2866
4799
  name: a.name,
@@ -2875,6 +4808,9 @@ export class Manager {
2875
4808
  status: a.handle.status(),
2876
4809
  uptimeMs: Date.now() - a.startedAt,
2877
4810
  mesh: roster.get(a.name)?.status ?? "absent",
4811
+ // The incarnation coordinate (SPEC 13.1) — with `id`, exactly what a v0.4 caller needs to
4812
+ // build a targeted (`despawn`/`attach`) request against THIS incarnation.
4813
+ lifecycleUid: a.lifecycleUid,
2878
4814
  ...(health && health.state !== "ok" ? { authHealth: health.state, authReason: health.reason } : {}),
2879
4815
  };
2880
4816
  });