@cotal-ai/cli 0.35.0 → 0.37.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 (115) hide show
  1. package/dist/commands/agents.js +1 -1
  2. package/dist/commands/agents.js.map +1 -1
  3. package/dist/commands/backup.d.ts.map +1 -1
  4. package/dist/commands/backup.js +4 -1
  5. package/dist/commands/backup.js.map +1 -1
  6. package/dist/commands/clean.d.ts +1 -1
  7. package/dist/commands/clean.d.ts.map +1 -1
  8. package/dist/commands/clean.js +48 -20
  9. package/dist/commands/clean.js.map +1 -1
  10. package/dist/commands/doctor.d.ts.map +1 -1
  11. package/dist/commands/doctor.js +21 -11
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/down-manifest.js +7 -7
  14. package/dist/commands/down-manifest.js.map +1 -1
  15. package/dist/commands/down.d.ts +10 -2
  16. package/dist/commands/down.d.ts.map +1 -1
  17. package/dist/commands/down.js +21 -10
  18. package/dist/commands/down.js.map +1 -1
  19. package/dist/commands/endpoints.d.ts +1 -1
  20. package/dist/commands/endpoints.d.ts.map +1 -1
  21. package/dist/commands/endpoints.js +1 -0
  22. package/dist/commands/endpoints.js.map +1 -1
  23. package/dist/commands/ext.d.ts.map +1 -1
  24. package/dist/commands/ext.js +3 -1
  25. package/dist/commands/ext.js.map +1 -1
  26. package/dist/commands/join.d.ts.map +1 -1
  27. package/dist/commands/join.js +5 -1
  28. package/dist/commands/join.js.map +1 -1
  29. package/dist/commands/mint.d.ts.map +1 -1
  30. package/dist/commands/mint.js +4 -3
  31. package/dist/commands/mint.js.map +1 -1
  32. package/dist/commands/setup.d.ts +34 -8
  33. package/dist/commands/setup.d.ts.map +1 -1
  34. package/dist/commands/setup.js +130 -267
  35. package/dist/commands/setup.js.map +1 -1
  36. package/dist/commands/spawn-manifest.js +5 -5
  37. package/dist/commands/spawn-manifest.js.map +1 -1
  38. package/dist/commands/spawn.d.ts.map +1 -1
  39. package/dist/commands/spawn.js +33 -24
  40. package/dist/commands/spawn.js.map +1 -1
  41. package/dist/commands/status.d.ts.map +1 -1
  42. package/dist/commands/status.js +44 -18
  43. package/dist/commands/status.js.map +1 -1
  44. package/dist/commands/up.d.ts +4 -2
  45. package/dist/commands/up.d.ts.map +1 -1
  46. package/dist/commands/up.js +204 -120
  47. package/dist/commands/up.js.map +1 -1
  48. package/dist/console/ui/Detail.js +1 -1
  49. package/dist/console/ui/Detail.js.map +1 -1
  50. package/dist/console/ui/Roster.d.ts.map +1 -1
  51. package/dist/console/ui/Roster.js +9 -2
  52. package/dist/console/ui/Roster.js.map +1 -1
  53. package/dist/console/ui/StatusBar.d.ts.map +1 -1
  54. package/dist/console/ui/StatusBar.js +1 -1
  55. package/dist/console/ui/StatusBar.js.map +1 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +9 -6
  58. package/dist/index.js.map +1 -1
  59. package/dist/lib/agent-skills.d.ts +4 -4
  60. package/dist/lib/agent-skills.js +4 -4
  61. package/dist/lib/control.d.ts.map +1 -1
  62. package/dist/lib/control.js +3 -2
  63. package/dist/lib/control.js.map +1 -1
  64. package/dist/lib/delivery-proc.d.ts +8 -7
  65. package/dist/lib/delivery-proc.d.ts.map +1 -1
  66. package/dist/lib/delivery-proc.js +69 -40
  67. package/dist/lib/delivery-proc.js.map +1 -1
  68. package/dist/lib/manager-proc.d.ts +16 -10
  69. package/dist/lib/manager-proc.d.ts.map +1 -1
  70. package/dist/lib/manager-proc.js +65 -36
  71. package/dist/lib/manager-proc.js.map +1 -1
  72. package/dist/lib/manifest/ledger.d.ts +7 -2
  73. package/dist/lib/manifest/ledger.d.ts.map +1 -1
  74. package/dist/lib/manifest/ledger.js +9 -4
  75. package/dist/lib/manifest/ledger.js.map +1 -1
  76. package/dist/lib/manifest/live.d.ts.map +1 -1
  77. package/dist/lib/manifest/live.js +3 -0
  78. package/dist/lib/manifest/live.js.map +1 -1
  79. package/dist/lib/restore.d.ts +6 -6
  80. package/dist/lib/restore.d.ts.map +1 -1
  81. package/dist/lib/restore.js +25 -15
  82. package/dist/lib/restore.js.map +1 -1
  83. package/dist/lib/status.d.ts +5 -2
  84. package/dist/lib/status.d.ts.map +1 -1
  85. package/dist/lib/status.js +8 -5
  86. package/dist/lib/status.js.map +1 -1
  87. package/dist/lib/transient.d.ts.map +1 -1
  88. package/dist/lib/transient.js +1 -0
  89. package/dist/lib/transient.js.map +1 -1
  90. package/dist/lib/version.d.ts +6 -0
  91. package/dist/lib/version.d.ts.map +1 -1
  92. package/dist/lib/version.js +14 -0
  93. package/dist/lib/version.js.map +1 -1
  94. package/dist/render.d.ts.map +1 -1
  95. package/dist/render.js +1 -0
  96. package/dist/render.js.map +1 -1
  97. package/dist/seed/authority.d.ts +5 -1
  98. package/dist/seed/authority.d.ts.map +1 -1
  99. package/dist/seed/authority.js +10 -3
  100. package/dist/seed/authority.js.map +1 -1
  101. package/dist/seed/paths.d.ts +6 -0
  102. package/dist/seed/paths.d.ts.map +1 -1
  103. package/dist/seed/paths.js +21 -15
  104. package/dist/seed/paths.js.map +1 -1
  105. package/dist/seed/reconcile.d.ts.map +1 -1
  106. package/dist/seed/reconcile.js +37 -5
  107. package/dist/seed/reconcile.js.map +1 -1
  108. package/dist/ui.js +1 -1
  109. package/dist/ui.js.map +1 -1
  110. package/dist/view/mesh-view.d.ts +4 -0
  111. package/dist/view/mesh-view.d.ts.map +1 -1
  112. package/dist/view/mesh-view.js +10 -0
  113. package/dist/view/mesh-view.js.map +1 -1
  114. package/package.json +6 -5
  115. package/cotal-skills/.claude-plugin/plugin.json +0 -6
@@ -3,10 +3,10 @@ import { randomUUID } from "node:crypto";
3
3
  import { createConnection, createServer } from "node:net";
4
4
  import { hostname } from "node:os";
5
5
  import { mkdirSync, writeFileSync, readFileSync, existsSync, openSync, statSync, readSync, closeSync, lstatSync, rmSync, realpathSync, } from "node:fs";
6
- import { join, resolve } from "node:path";
6
+ import { dirname, join, resolve } from "node:path";
7
7
  import { isReachable, DEFAULT_SERVER, createSpaceAuth, serverConfig, openServerConfig, validateTlsMaterial, mintCreds, mintLifecycleUid, DEV_OWNER, mintConnectionEvictorCreds, mintMembershipObserverCreds, newIdentity, setupSpaceStreams, reconcileSpaceTtls, standaloneConnectOpts, seedChannelRegistry, ensureDefaultDeliveryClass, mkSecretDir, writeSecretFile, } from "@cotal-ai/core";
8
8
  import { connect } from "@nats-io/transport-node";
9
- import { assertSingleSpaceBroker, assertUserAuthInfo, authDir, getSoleSpaceAuth, getSpaceAuth, hasUserAuthState, listSpaceAccounts, preloadSpaceAccounts, putSpaceAuth, clearCurrent, findMesh, getCurrent, loadMeshes, MEMBERSHIP_RW_CREDS_KEY, recordMesh, meshesForRoot, removeMesh, rotateSystemCreds, setCurrent, staleSystemCreds, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, acquireMaintenanceLock, assertStoreIdentity, assessRestoreClaim, beginOrdinaryResume, bindOrdinaryResumeListener, consumeRetiredMaintenance, markOrdinaryResumeActive, markOrdinaryResumeDegraded, replaceDeadOrdinaryResumeListener, localProcessOwnerStatus, readMaintenanceJournal, readMaintenanceResumeDocument, readStoreIdentity, recordOrdinaryResumeManagerCommit, releaseMaintenanceLock, retireOrdinaryResume, sameStoreIdentity, readBrokerPolicy, writeBrokerPolicy, } from "@cotal-ai/workspace";
9
+ import { assertSingleSpaceBroker, assertUserAuthInfo, authDir, getSoleSpaceAuth, getSpaceAuth, hasUserAuthState, listSpaceAccounts, preloadSpaceAccounts, putSpaceAuth, clearCurrent, findMesh, getCurrent, loadMeshes, connectionEvictorCredsKey, membershipConfigPath, membershipObserverCredsKey, membershipRwCredsKey, MEMBERSHIP_CONFIG_KIND, MEMBERSHIP_RW_CREDS_KIND, recordMesh, meshesForRoot, removeMesh, rotateSystemCreds, setCurrent, staleSystemCreds, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, acquireMaintenanceLock, assertStoreIdentity, assessRestoreClaim, beginOrdinaryResume, bindOrdinaryResumeListener, consumeRetiredMaintenance, markOrdinaryResumeActive, markOrdinaryResumeDegraded, replaceDeadOrdinaryResumeListener, localProcessOwnerStatus, readMaintenanceJournal, readMaintenanceResumeDocument, readStoreIdentity, recordOrdinaryResumeManagerCommit, releaseMaintenanceLock, retireOrdinaryResume, sameStoreIdentity, readBrokerPolicy, writeBrokerPolicy, } from "@cotal-ai/workspace";
10
10
  import { ensureAuthService, resolveAuthProvider, stopAuthService } from "../lib/auth-proc.js";
11
11
  import { resolveSpace } from "../lib/status.js";
12
12
  import { c } from "../ui.js";
@@ -14,7 +14,7 @@ import { resolveNatsServer } from "../lib/nats-bin.js";
14
14
  import { cotalPath, cotalRoot } from "../lib/paths.js";
15
15
  import { renderDetachedSummary } from "../lib/up-report.js";
16
16
  import { deliveryUp, ensureControlPlane, stopDelivery } from "../lib/delivery-proc.js";
17
- import { managerHasDeliveryMarker, managerUp, stopManager } from "../lib/manager-proc.js";
17
+ import { managerHasDeliveryMarker, managerLogDisplayPath, managerUp, stopManager } from "../lib/manager-proc.js";
18
18
  import { loadManifest } from "../lib/manifest/index.js";
19
19
  import { buildLaunchSpec, genRunId, manifestToChannels, preflightConnectors, writeLaunchSpec } from "../lib/manifest/apply.js";
20
20
  import { renderUpPlan, renderInherited, renderWarnings } from "../lib/manifest/render.js";
@@ -66,7 +66,24 @@ export function upComplete(argv) {
66
66
  const items = upFlags.map((f) => ({ value: `--${f.name}`, description: f.description }));
67
67
  return { items, directive: items.length ? "nofiles" : "default" };
68
68
  }
69
- export async function up(args) {
69
+ /** `inheritedLock` is the root maintenance lock a recovery re-entry hands down (see the re-entry
70
+ * below). Ownership transfers with it: this call releases it at its own release point. */
71
+ export async function up(args, inheritedLock) {
72
+ if (!inheritedLock)
73
+ return await runUp(args);
74
+ // A re-entry owns the inherited lock from the moment it is called, but `runUp` only adopts it
75
+ // part-way in — an early refusal (an unusable `--runtime`, say) throws before that. Release it
76
+ // here in exactly that case, so a refused re-entry never leaves the root locked for the reaper.
77
+ let adopted = false;
78
+ try {
79
+ return await runUp(args, inheritedLock, () => { adopted = true; });
80
+ }
81
+ finally {
82
+ if (!adopted)
83
+ releaseMaintenanceLock(inheritedLock);
84
+ }
85
+ }
86
+ async function runUp(args, inheritedLock, onAdopt) {
70
87
  const values = args.values;
71
88
  if (values.restore) {
72
89
  if (values.file || values.channels)
@@ -101,6 +118,11 @@ export async function up(args) {
101
118
  }
102
119
  catch (error) {
103
120
  pendingRestores.delete(prepared.attemptId);
121
+ // The one journal writer deliberately NOT handed a lock, and the reason is structural rather
122
+ // than stylistic: no lock is held in this frame either way. This invocation returns just below
123
+ // without ever reaching the `startupLock` acquire, and the re-entry that did hold one released
124
+ // it in its own `finally` on the way out through this very throw. So the helper must take its
125
+ // own — passing something here would mean passing `undefined`, which reads as an oversight.
104
126
  markPreparedRestoreDegraded(prepared.root, prepared.attemptId, error.message);
105
127
  throw error;
106
128
  }
@@ -127,6 +149,9 @@ export async function up(args) {
127
149
  const lock = acquireMaintenanceLock(root);
128
150
  let pending;
129
151
  let recoveredRestore;
152
+ // Set only at the moment the lock is passed to a re-entry, which then owns releasing it. Any
153
+ // other exit from this block — including a throw after `pending` was built — still releases.
154
+ let handedOff = false;
130
155
  try {
131
156
  const journal = readMaintenanceJournal(root);
132
157
  if (journal?.state === "restore-ready") {
@@ -271,56 +296,64 @@ export async function up(args) {
271
296
  else if (journal) {
272
297
  throw new Error(`cotal up is refused while maintenance state is ${journal.state}; follow the recorded recovery`);
273
298
  }
274
- }
275
- finally {
276
- releaseMaintenanceLock(lock);
277
- }
278
- if (recoveredRestore) {
279
- try {
280
- await up({
281
- ...args,
282
- values: {
283
- ...values,
284
- __restoreAttempt: recoveredRestore.attemptId,
285
- space: recoveredRestore.space,
286
- server: recoveredRestore.server,
287
- host: recoveredRestore.host,
288
- "store-dir": recoveredRestore.targetPath,
289
- runtime: recoveredRestore.runtime,
290
- detach: recoveredRestore.detached,
291
- open: recoveredRestore.mode === "open",
292
- "user-auth": recoveredRestore.mode === "user",
293
- },
294
- });
299
+ // The re-entries run INSIDE this block, under the lock the journal was just written with, and
300
+ // inherit it rather than letting it drop. Releasing here and re-acquiring in the nested `up`
301
+ // left the journal reading `resume-intent` with the lock free — the window a concurrent `up`
302
+ // could render the whole-broker server.conf through (design doc §4, P2).
303
+ if (recoveredRestore) {
304
+ handedOff = true;
305
+ try {
306
+ await up({
307
+ ...args,
308
+ values: {
309
+ ...values,
310
+ __restoreAttempt: recoveredRestore.attemptId,
311
+ space: recoveredRestore.space,
312
+ server: recoveredRestore.server,
313
+ host: recoveredRestore.host,
314
+ "store-dir": recoveredRestore.targetPath,
315
+ runtime: recoveredRestore.runtime,
316
+ detach: recoveredRestore.detached,
317
+ open: recoveredRestore.mode === "open",
318
+ "user-auth": recoveredRestore.mode === "user",
319
+ },
320
+ }, lock);
321
+ }
322
+ catch (error) {
323
+ // The nested `up` released the inherited lock on its way out, so this takes its own.
324
+ markPendingResumeDegraded(recoveredRestore.attemptId, error instanceof Error ? error.message : String(error));
325
+ throw error;
326
+ }
327
+ return;
295
328
  }
296
- catch (error) {
297
- markPendingResumeDegraded(recoveredRestore.attemptId, error instanceof Error ? error.message : String(error));
298
- throw error;
329
+ if (pending) {
330
+ handedOff = true;
331
+ try {
332
+ await up({
333
+ ...args,
334
+ values: {
335
+ ...values,
336
+ __ordinaryResumeAttempt: pending.attemptId,
337
+ space: pending.space,
338
+ server: pending.server,
339
+ "store-dir": pending.storeDir,
340
+ runtime: pending.runtime,
341
+ detach: pending.detached,
342
+ open: pending.mode === "open",
343
+ "user-auth": pending.mode === "user",
344
+ },
345
+ }, lock);
346
+ }
347
+ catch (error) {
348
+ markPendingResumeDegraded(pending.attemptId, error instanceof Error ? error.message : String(error));
349
+ throw error;
350
+ }
351
+ return;
299
352
  }
300
- return;
301
353
  }
302
- if (pending) {
303
- try {
304
- await up({
305
- ...args,
306
- values: {
307
- ...values,
308
- __ordinaryResumeAttempt: pending.attemptId,
309
- space: pending.space,
310
- server: pending.server,
311
- "store-dir": pending.storeDir,
312
- runtime: pending.runtime,
313
- detach: pending.detached,
314
- open: pending.mode === "open",
315
- "user-auth": pending.mode === "user",
316
- },
317
- });
318
- }
319
- catch (error) {
320
- markPendingResumeDegraded(pending.attemptId, error instanceof Error ? error.message : String(error));
321
- throw error;
322
- }
323
- return;
354
+ finally {
355
+ if (!handedOff)
356
+ releaseMaintenanceLock(lock);
324
357
  }
325
358
  }
326
359
  const wantUser = Boolean(values["user-auth"]);
@@ -454,7 +487,31 @@ export async function up(args) {
454
487
  if (values.runtime)
455
488
  await preflightRuntime(values.runtime);
456
489
  const resumeAttempt = values.__restoreAttempt ?? values.__ordinaryResumeAttempt;
457
- let startupLock = resumeAttempt ? undefined : acquireMaintenanceLock(cotalRoot());
490
+ // Held for EVERY `up`, resume re-entry included. A re-entry used to hold no lock at all and still
491
+ // reach the renderer below, so a concurrent `cotal up` could rewrite the whole-broker server.conf
492
+ // — every tenant's trust, unserialized — while a resume was mid-flight (design doc §4, P2). A
493
+ // re-entry INHERITS the recovery's lock rather than taking its own: the recovery journals
494
+ // `resume-intent` under that lock, so releasing it to re-acquire here would leave exactly the
495
+ // window the probe catches — the journal already in a resume state with the lock free.
496
+ //
497
+ // Inheriting makes handing this lock down MANDATORY, not tidy: the lock is not reentrant, and it
498
+ // cannot stale-reap its way out either, because the recorded owner is alive — it is us. A helper
499
+ // that self-acquired would take the "held by a live owner" refusal and fail the resume outright.
500
+ // So EVERY helper below that journals under this lock takes it as a parameter, on both the restore
501
+ // and the ordinary side: the two ADOPT paths (proven-restore and proven-ordinary listeners, which
502
+ // recover by adopting an already-live listener rather than spawning a competitor), the spawn-path
503
+ // listener binds (detached and foreground, restore and ordinary), the dead-listener replacement,
504
+ // `completeResumeActivation` and the manager-commit / activation / degraded writers it calls.
505
+ //
506
+ // Read that list as a rule, not an inventory. The trap is that a call site with no lock ARGUMENT
507
+ // says nothing about whether the CALLEE acquires: the restore-side writers took no parameter and
508
+ // self-acquired, so handing the lock to the sites that visibly named it left `up --restore` dead
509
+ // on its first pass — it died in the listener bind, before any of the seams. If you add a helper
510
+ // that journals, give it `heldLock` and hand it down from here; do not audit by call-site shape.
511
+ // The one caller that does NOT pass it is the prepare-failure path above, where the lock has
512
+ // genuinely not been taken yet in this frame and the helper is meant to take its own.
513
+ let startupLock = inheritedLock ?? acquireMaintenanceLock(cotalRoot());
514
+ onAdopt?.(); // from here the release below owns it, inherited or not
458
515
  const releaseStartupLock = () => {
459
516
  if (!startupLock)
460
517
  return;
@@ -462,7 +519,10 @@ export async function up(args) {
462
519
  startupLock = undefined;
463
520
  };
464
521
  try {
465
- if (startupLock)
522
+ // Gated on the ATTEMPT, not on holding the lock. The lock is now always held, and this refuses
523
+ // whenever a journal exists — which is precisely the state a resume re-entry is in, so gating
524
+ // it on the lock would refuse the very resume the lock is here to protect.
525
+ if (!resumeAttempt)
466
526
  assertOrdinaryUpAllowed(cotalRoot(), values["store-dir"] ? resolve(values["store-dir"]) : cotalPath("nats"));
467
527
  let server = values.server ?? DEFAULT_SERVER;
468
528
  const host = values.host ?? "127.0.0.1";
@@ -480,7 +540,7 @@ export async function up(args) {
480
540
  throw new Error(`restore attempt ${resumeAttempt} re-entry has no bound listener proof; preserving recovery state`);
481
541
  const ownerStatus = localProcessOwnerStatus(restoredAttempt.listenerProof.processOwner);
482
542
  if (ownerStatus === "alive") {
483
- await resumeProvenRestoreListener(restoredAttempt);
543
+ await resumeProvenRestoreListener(restoredAttempt, startupLock);
484
544
  return;
485
545
  }
486
546
  if (ownerStatus === "unknown")
@@ -489,13 +549,13 @@ export async function up(args) {
489
549
  throw new Error(`restore attempt ${resumeAttempt} is manager-committed but its bound listener is dead; preserving the commit token and retained suppression`);
490
550
  if (await isReachable(restoredAttempt.server))
491
551
  throw new Error(`restore attempt ${resumeAttempt} refuses the occupied foreign listener at ${restoredAttempt.server}`);
492
- replacePreparedDeadRestoreListener(restoredAttempt);
552
+ replacePreparedDeadRestoreListener(restoredAttempt, startupLock);
493
553
  }
494
554
  const ordinaryAttempt = resumeAttempt ? pendingOrdinaryResumes.get(resumeAttempt) : undefined;
495
555
  if (ordinaryAttempt?.adoptProof) {
496
556
  // The recovered attempt's exact bound listener is alive: prove it over the wire and adopt it
497
557
  // instead of spawning a competitor over the same store.
498
- await resumeProvenOrdinaryListener(ordinaryAttempt);
558
+ await resumeProvenOrdinaryListener(ordinaryAttempt, startupLock);
499
559
  return;
500
560
  }
501
561
  if (ordinaryAttempt?.journalState === "resume-committed")
@@ -642,7 +702,7 @@ export async function up(args) {
642
702
  // delivery marker) is stopped and REPLACED by the ensure below carrying the requested runtime,
643
703
  // so that's not a reuse - don't claim the runtime is fixed. A dead/absent manager is (re)started
644
704
  // with it.
645
- if (values.runtime && managerUp() && managerHasDeliveryMarker())
705
+ if (values.runtime && managerUp(held.space) && managerHasDeliveryMarker(held.space))
646
706
  console.error(c.dim(`! manager already running for "${held.space}" - its runtime is fixed at start; \`cotal down\` then \`cotal up --runtime ${values.runtime}\` to change it`));
647
707
  // A repair replaces the manager, so it must carry the mesh's recorded exposure forward — a
648
708
  // bare `cotal up` here has no `--host` of its own, and dropping it silently moves the attach
@@ -713,14 +773,14 @@ export async function up(args) {
713
773
  boundListener: {
714
774
  serverName: restored.serverName,
715
775
  serverNonce: restored.serverNonce,
716
- onSpawn: (pid, startedAt) => bindSpawnedRestoreListener(restored, pid, startedAt),
776
+ onSpawn: (pid, startedAt) => bindSpawnedRestoreListener(restored, pid, startedAt, startupLock),
717
777
  verify: async () => { await provePreparedRestoreListener(restored); },
718
778
  },
719
779
  } : ordinaryAttempt ? {
720
780
  boundListener: {
721
781
  serverName: ordinaryAttempt.serverName,
722
782
  serverNonce: ordinaryAttempt.serverNonce,
723
- onSpawn: (pid, startedAt) => bindSpawnedOrdinaryResumeListener(ordinaryAttempt, pid, startedAt),
783
+ onSpawn: (pid, startedAt) => bindSpawnedOrdinaryResumeListener(ordinaryAttempt, pid, startedAt, startupLock),
724
784
  verify: async () => { await verifySpawnedOrdinaryListener(ordinaryAttempt); },
725
785
  },
726
786
  } : {}),
@@ -736,7 +796,7 @@ export async function up(args) {
736
796
  // exit code, not only in the red line above.
737
797
  if (!authService)
738
798
  process.exitCode = 1;
739
- await completeResumeActivation(resumeAttempt, controlPlane && authService, !authService ? "normal listener started but the user-auth service is unavailable" : "normal listener started but the control plane is degraded", server);
799
+ await completeResumeActivation(resumeAttempt, controlPlane && authService, !authService ? "normal listener started but the user-auth service is unavailable" : "normal listener started but the control plane is degraded", server, startupLock);
740
800
  return;
741
801
  }
742
802
  const useAuth = !values.open;
@@ -785,7 +845,7 @@ export async function up(args) {
785
845
  process.exit(87);
786
846
  if (restored)
787
847
  try {
788
- bindSpawnedRestoreListener(restored, child.pid ?? 0, listenerStartedAt);
848
+ bindSpawnedRestoreListener(restored, child.pid ?? 0, listenerStartedAt, startupLock);
789
849
  }
790
850
  catch (error) {
791
851
  await stopUnboundRestoreListener(child);
@@ -794,7 +854,7 @@ export async function up(args) {
794
854
  }
795
855
  if (ordinaryAttempt)
796
856
  try {
797
- bindSpawnedOrdinaryResumeListener(ordinaryAttempt, child.pid ?? 0, listenerStartedAt);
857
+ bindSpawnedOrdinaryResumeListener(ordinaryAttempt, child.pid ?? 0, listenerStartedAt, startupLock);
798
858
  }
799
859
  catch (error) {
800
860
  await stopUnboundRestoreListener(child);
@@ -816,10 +876,10 @@ export async function up(args) {
816
876
  // must run even if it fails — the failure is logged, never swallowed silently, and the daemon kill
817
877
  // itself happens inside stopDelivery's finally. Order preserved: delivery, manager, auth, broker.
818
878
  const stop = () => {
819
- void stopDelivery()
879
+ void stopDelivery(undefined, undefined, space)
820
880
  .catch((e) => console.error(`! delivery teardown: ${e.message}`))
821
881
  .then(() => {
822
- void stopManager()
882
+ void stopManager(undefined, undefined, undefined, space)
823
883
  .then(() => stopAuthService(space))
824
884
  .catch((e) => console.error(`! teardown: ${e.message}`));
825
885
  child.kill("SIGTERM");
@@ -832,8 +892,8 @@ export async function up(args) {
832
892
  child.on("exit", async (code) => {
833
893
  rmSync(cotalPath("nats.pid"), { force: true });
834
894
  // Logged, never silently swallowed; the daemon kill runs in stopDelivery's finally regardless.
835
- await stopDelivery().catch((e) => console.error(`! delivery teardown: ${e.message}`));
836
- await stopManager().catch((e) => console.error(`! manager teardown: ${e.message}`));
895
+ await stopDelivery(undefined, undefined, space).catch((e) => console.error(`! delivery teardown: ${e.message}`));
896
+ await stopManager(undefined, undefined, undefined, space).catch((e) => console.error(`! manager teardown: ${e.message}`));
837
897
  await stopAuthService(space).catch((e) => console.error(`! auth teardown: ${e.message}`));
838
898
  // Only unrecord if the registry still points at THIS broker. A newer broker for the same space
839
899
  // (a concurrent `up`, or a different-port re-up that recorded after us) may have replaced our
@@ -855,7 +915,9 @@ export async function up(args) {
855
915
  if (!ready) {
856
916
  child.kill("SIGTERM");
857
917
  const reason = `nats-server did not become ready at ${server}`;
858
- markPendingResumeDegraded(resumeAttempt ?? "", reason);
918
+ // `startupLock` is already released by here on this path (and the helper then takes its own);
919
+ // passed anyway so the call stays correct if the release above ever moves.
920
+ markPendingResumeDegraded(resumeAttempt ?? "", reason, startupLock);
859
921
  throw new Error(reason);
860
922
  }
861
923
  if (restored)
@@ -910,7 +972,7 @@ export async function up(args) {
910
972
  });
911
973
  if (restored && process.env.COTAL_SMOKE_FAIL_AFTER_RESTORE_LISTENER_READY === "1")
912
974
  throw new Error("smoke-injected failure after restore listener readiness");
913
- await completeResumeActivation(resumeAttempt, controlPlane && svc.ok, !svc.ok ? "normal listener started but the user-auth service is unavailable" : "normal listener started but the control plane is degraded", server);
975
+ await completeResumeActivation(resumeAttempt, controlPlane && svc.ok, !svc.ok ? "normal listener started but the user-auth service is unavailable" : "normal listener started but the control plane is degraded", server, startupLock);
914
976
  activationFinished = true;
915
977
  }
916
978
  await new Promise(() => { });
@@ -943,10 +1005,10 @@ function assertOrdinaryUpAllowed(root, storeDir) {
943
1005
  throw new Error("ordinary resume must begin through the attempt-bound startup path");
944
1006
  throw new Error(`cotal up is refused while maintenance state is ${maintenance.state}; follow the recorded restore recovery`);
945
1007
  }
946
- function markPendingResumeDegraded(attemptId, reason) {
1008
+ function markPendingResumeDegraded(attemptId, reason, heldLock) {
947
1009
  const ordinary = pendingOrdinaryResumes.get(attemptId);
948
1010
  if (ordinary) {
949
- const lock = acquireMaintenanceLock(ordinary.root);
1011
+ const lock = heldLock ?? acquireMaintenanceLock(ordinary.root);
950
1012
  try {
951
1013
  const journal = readMaintenanceJournal(ordinary.root);
952
1014
  if (journal && (journal.state === "resume-intent" || journal.state === "resume-active"))
@@ -957,13 +1019,14 @@ function markPendingResumeDegraded(attemptId, reason) {
957
1019
  }]);
958
1020
  }
959
1021
  finally {
960
- releaseMaintenanceLock(lock);
1022
+ if (!heldLock)
1023
+ releaseMaintenanceLock(lock);
961
1024
  }
962
1025
  return;
963
1026
  }
964
1027
  const restored = pendingRestores.get(attemptId);
965
1028
  if (restored)
966
- markPreparedRestoreDegraded(restored.root, restored.attemptId, reason);
1029
+ markPreparedRestoreDegraded(restored.root, restored.attemptId, reason, heldLock);
967
1030
  }
968
1031
  async function resumeControlAuth(root, mode) {
969
1032
  if (mode === "open")
@@ -1021,11 +1084,11 @@ async function stopUnboundRestoreListener(child) {
1021
1084
  if (!await waitForChildExit(child, 5_000))
1022
1085
  throw new Error(`unbound restore listener process ${child.pid ?? "unknown"} did not exit`);
1023
1086
  }
1024
- function bindSpawnedRestoreListener(prepared, pid, startedAt) {
1025
- bindRestoreListenerOwner(prepared, restoreListenerOwner(pid, prepared.serverNonce, startedAt));
1087
+ function bindSpawnedRestoreListener(prepared, pid, startedAt, heldLock) {
1088
+ bindRestoreListenerOwner(prepared, restoreListenerOwner(pid, prepared.serverNonce, startedAt), heldLock);
1026
1089
  }
1027
- function bindRestoreListenerOwner(prepared, processOwner) {
1028
- bindPreparedRestoreListener(prepared, processOwner);
1090
+ function bindRestoreListenerOwner(prepared, processOwner, heldLock) {
1091
+ bindPreparedRestoreListener(prepared, processOwner, heldLock);
1029
1092
  if (process.env.COTAL_SMOKE_EXIT_AFTER_RESTORE_LISTENER_BIND === "1")
1030
1093
  process.exit(86);
1031
1094
  }
@@ -1146,10 +1209,10 @@ async function provePreparedRestoreListener(prepared) {
1146
1209
  }
1147
1210
  return proof;
1148
1211
  }
1149
- function bindSpawnedOrdinaryResumeListener(pending, pid, startedAt) {
1212
+ function bindSpawnedOrdinaryResumeListener(pending, pid, startedAt, heldLock) {
1150
1213
  if (!Number.isInteger(pid) || pid <= 0)
1151
1214
  throw new Error("resume listener spawn returned no pid");
1152
- const lock = acquireMaintenanceLock(pending.root);
1215
+ const lock = heldLock ?? acquireMaintenanceLock(pending.root);
1153
1216
  try {
1154
1217
  const journal = readMaintenanceJournal(pending.root);
1155
1218
  if (!journal || !("ordinaryResume" in journal) || journal.ordinaryResume.attemptId !== pending.attemptId)
@@ -1164,7 +1227,8 @@ function bindSpawnedOrdinaryResumeListener(pending, pid, startedAt) {
1164
1227
  });
1165
1228
  }
1166
1229
  finally {
1167
- releaseMaintenanceLock(lock);
1230
+ if (!heldLock)
1231
+ releaseMaintenanceLock(lock);
1168
1232
  }
1169
1233
  }
1170
1234
  /** Prove the recovered attempt's live bound listener IS ours end-to-end before adoption: launch
@@ -1235,7 +1299,7 @@ async function verifySpawnedOrdinaryListener(pending) {
1235
1299
  if (info.server_name !== pending.serverName)
1236
1300
  throw new Error(`resume attempt ${pending.attemptId} spawned listener reports a foreign NATS server name`);
1237
1301
  }
1238
- async function resumeProvenOrdinaryListener(pending) {
1302
+ async function resumeProvenOrdinaryListener(pending, heldLock) {
1239
1303
  await proveOrdinaryResumeListener(pending);
1240
1304
  const svc = await ensureRecoveredUserAuth(pending);
1241
1305
  // Read the recorded exposure BEFORE re-recording: `recordOurMesh` writes the entry whole, so a
@@ -1259,7 +1323,7 @@ async function resumeProvenOrdinaryListener(pending) {
1259
1323
  resumeAttempt: pending.attemptId,
1260
1324
  resumeCommitToken: pending.managerCommit?.durableCommitToken,
1261
1325
  });
1262
- await completeResumeActivation(pending.attemptId, controlPlane && svc.ok, !svc.ok ? "adopted resume listener has no user-auth service" : "adopted resume listener has a degraded control plane", pending.server);
1326
+ await completeResumeActivation(pending.attemptId, controlPlane && svc.ok, !svc.ok ? "adopted resume listener has no user-auth service" : "adopted resume listener has a degraded control plane", pending.server, heldLock);
1263
1327
  }
1264
1328
  async function ensureRecoveredUserAuth(prepared) {
1265
1329
  if (prepared.mode !== "user")
@@ -1277,7 +1341,7 @@ async function ensureRecoveredUserAuth(prepared) {
1277
1341
  });
1278
1342
  return startUserAuthService(prepared.space, prepared.server, { prepared: provider, stateDir });
1279
1343
  }
1280
- async function resumeProvenRestoreListener(prepared) {
1344
+ async function resumeProvenRestoreListener(prepared, heldLock) {
1281
1345
  await provePreparedRestoreListener(prepared);
1282
1346
  const svc = await ensureRecoveredUserAuth(prepared);
1283
1347
  // Same ordering constraint as the pending-adopt path above: capture the recorded exposure before
@@ -1301,9 +1365,9 @@ async function resumeProvenRestoreListener(prepared) {
1301
1365
  resumeAttempt: prepared.attemptId,
1302
1366
  resumeCommitToken: prepared.managerCommit?.durableCommitToken,
1303
1367
  });
1304
- await completeResumeActivation(prepared.attemptId, controlPlane && svc.ok, !svc.ok ? "proven restore listener has no user-auth service" : "proven restore listener has a degraded control plane", prepared.server);
1368
+ await completeResumeActivation(prepared.attemptId, controlPlane && svc.ok, !svc.ok ? "proven restore listener has no user-auth service" : "proven restore listener has a degraded control plane", prepared.server, heldLock);
1305
1369
  }
1306
- async function completeResumeActivation(attemptId, healthy, reason, server) {
1370
+ async function completeResumeActivation(attemptId, healthy, reason, server, heldLock) {
1307
1371
  if (!attemptId)
1308
1372
  return;
1309
1373
  const ordinary = pendingOrdinaryResumes.get(attemptId);
@@ -1312,7 +1376,7 @@ async function completeResumeActivation(attemptId, healthy, reason, server) {
1312
1376
  if (!pending)
1313
1377
  throw new Error(`resume activation lost attempt context ${attemptId}`);
1314
1378
  if (!healthy) {
1315
- markPendingResumeDegraded(attemptId, reason);
1379
+ markPendingResumeDegraded(attemptId, reason, heldLock);
1316
1380
  throw new Error(reason);
1317
1381
  }
1318
1382
  let journal = readMaintenanceJournal(pending.root);
@@ -1331,12 +1395,13 @@ async function completeResumeActivation(attemptId, healthy, reason, server) {
1331
1395
  if (!("ordinaryResume" in journal) || journal.ordinaryResume.attemptId !== attemptId)
1332
1396
  throw new Error(`ordinary resume journal does not match attempt ${attemptId}`);
1333
1397
  if (journal.state === "resume-retired") {
1334
- const lock = acquireMaintenanceLock(ordinary.root);
1398
+ const lock = heldLock ?? acquireMaintenanceLock(ordinary.root);
1335
1399
  try {
1336
1400
  consumeRetiredMaintenance(lock);
1337
1401
  }
1338
1402
  finally {
1339
- releaseMaintenanceLock(lock);
1403
+ if (!heldLock)
1404
+ releaseMaintenanceLock(lock);
1340
1405
  }
1341
1406
  pendingOrdinaryResumes.delete(attemptId);
1342
1407
  return;
@@ -1364,7 +1429,7 @@ async function completeResumeActivation(attemptId, healthy, reason, server) {
1364
1429
  break;
1365
1430
  if (Date.now() >= readinessDeadline) {
1366
1431
  const why = ready.error ?? "no manager answered within the readiness deadline";
1367
- markPendingResumeDegraded(attemptId, why);
1432
+ markPendingResumeDegraded(attemptId, why, heldLock);
1368
1433
  throw new Error(why);
1369
1434
  }
1370
1435
  await new Promise((resolveWait) => setTimeout(resolveWait, 150));
@@ -1378,13 +1443,13 @@ async function completeResumeActivation(attemptId, healthy, reason, server) {
1378
1443
  if (!resumed.ok) {
1379
1444
  const detail = resumed.data ? ` (${JSON.stringify(resumed.data)})` : "";
1380
1445
  const message = `${resumed.error ?? "retained-agent resume failed"}${detail}`;
1381
- markPendingResumeDegraded(attemptId, message);
1446
+ markPendingResumeDegraded(attemptId, message, heldLock);
1382
1447
  throw new Error(message);
1383
1448
  }
1384
1449
  if (restored && process.env.COTAL_SMOKE_EXIT_AFTER_RESUME_PRESERVED === "1")
1385
1450
  process.exit(88);
1386
1451
  if (ordinary && !managerCommit) {
1387
- const lock = acquireMaintenanceLock(ordinary.root);
1452
+ const lock = heldLock ?? acquireMaintenanceLock(ordinary.root);
1388
1453
  try {
1389
1454
  const current = readMaintenanceJournal(ordinary.root);
1390
1455
  if (current?.state !== "resume-active") {
@@ -1398,35 +1463,37 @@ async function completeResumeActivation(attemptId, healthy, reason, server) {
1398
1463
  ordinary.journalState = "resume-active";
1399
1464
  }
1400
1465
  finally {
1401
- releaseMaintenanceLock(lock);
1466
+ if (!heldLock)
1467
+ releaseMaintenanceLock(lock);
1402
1468
  }
1403
1469
  }
1404
1470
  if (!managerCommit) {
1405
1471
  const committed = await askManager(pending.space, server, "commitResume", { attemptId }, auth, "any", 40_000);
1406
1472
  if (!committed.ok) {
1407
1473
  const message = committed.error ?? "manager resume commit failed";
1408
- markPendingResumeDegraded(attemptId, message);
1474
+ markPendingResumeDegraded(attemptId, message, heldLock);
1409
1475
  throw new Error(message);
1410
1476
  }
1411
1477
  if (!isManagerCommitResult(committed.data, attemptId)) {
1412
1478
  const message = `manager resume commit returned invalid awaiting-finalize evidence for attempt ${attemptId}`;
1413
- markPendingResumeDegraded(attemptId, message);
1479
+ markPendingResumeDegraded(attemptId, message, heldLock);
1414
1480
  throw new Error(message);
1415
1481
  }
1416
1482
  managerCommit = committed.data;
1417
1483
  if (ordinary) {
1418
- const lock = acquireMaintenanceLock(ordinary.root);
1484
+ const lock = heldLock ?? acquireMaintenanceLock(ordinary.root);
1419
1485
  try {
1420
1486
  recordOrdinaryResumeManagerCommit(lock, managerCommit);
1421
1487
  ordinary.journalState = "resume-committed";
1422
1488
  ordinary.managerCommit = managerCommit;
1423
1489
  }
1424
1490
  finally {
1425
- releaseMaintenanceLock(lock);
1491
+ if (!heldLock)
1492
+ releaseMaintenanceLock(lock);
1426
1493
  }
1427
1494
  }
1428
1495
  else {
1429
- recordPreparedRestoreManagerCommit(restored, managerCommit);
1496
+ recordPreparedRestoreManagerCommit(restored, managerCommit, heldLock);
1430
1497
  }
1431
1498
  if (process.env.COTAL_SMOKE_EXIT_AFTER_RESUME_COMMIT === "1")
1432
1499
  process.exit(89);
@@ -1457,19 +1524,20 @@ async function completeResumeActivation(attemptId, healthy, reason, server) {
1457
1524
  if (process.env.COTAL_SMOKE_EXIT_AFTER_RESUME_FINALIZE === "1")
1458
1525
  process.exit(91);
1459
1526
  if (ordinary) {
1460
- const lock = acquireMaintenanceLock(ordinary.root);
1527
+ const lock = heldLock ?? acquireMaintenanceLock(ordinary.root);
1461
1528
  try {
1462
1529
  retireOrdinaryResume(lock, finalizeEvidence);
1463
1530
  consumeRetiredMaintenance(lock);
1464
1531
  }
1465
1532
  finally {
1466
- releaseMaintenanceLock(lock);
1533
+ if (!heldLock)
1534
+ releaseMaintenanceLock(lock);
1467
1535
  }
1468
1536
  pendingOrdinaryResumes.delete(attemptId);
1469
1537
  }
1470
1538
  else {
1471
1539
  restored.managerCommit = managerCommit;
1472
- markPreparedRestoreActive(restored, finalizeEvidence);
1540
+ markPreparedRestoreActive(restored, finalizeEvidence, heldLock);
1473
1541
  restored.cleanupStage();
1474
1542
  pendingRestores.delete(attemptId);
1475
1543
  }
@@ -1627,7 +1695,7 @@ async function upManifest(file, opts) {
1627
1695
  // A leftover detached manager (its broker is gone — the reachability check above proved nothing
1628
1696
  // lives at this address) would win the fresh mesh's lease and the launch manager would refuse.
1629
1697
  // Stop it, so the manager started below WITH the launch spec is THE manager.
1630
- await stopManager();
1698
+ await stopManager(undefined, undefined, undefined, m.space);
1631
1699
  let pid;
1632
1700
  let controlPlane = false;
1633
1701
  let authService = true;
@@ -1647,7 +1715,7 @@ async function upManifest(file, opts) {
1647
1715
  // degraded control plane (announced above) means the agents are NOT coming up.
1648
1716
  if (controlPlane) {
1649
1717
  // U6: on a user mesh the agents run under the OPERATOR's identity — say whose they are.
1650
- console.log(c.green(`✓ launching ${eff.agents.length} agent(s)`) + c.dim(` via manager (${runtime})${owner ? ` as you (owner ${owner})` : ""} - see .cotal/manager.log`));
1718
+ console.log(c.green(`✓ launching ${eff.agents.length} agent(s)`) + c.dim(` via manager (${runtime})${owner ? ` as you (owner ${owner})` : ""} - see ${managerLogDisplayPath(m.space)}`));
1651
1719
  }
1652
1720
  else {
1653
1721
  console.error(c.red(`✗ ${eff.agents.length} agent(s) NOT launched - the control plane did not come up (see above)`));
@@ -1869,8 +1937,8 @@ opts) {
1869
1937
  source,
1870
1938
  controlPlane,
1871
1939
  authService: svc.ok,
1872
- delivery: useAuth && deliveryUp(),
1873
- manager: managerUp(),
1940
+ delivery: useAuth && deliveryUp(space),
1941
+ manager: managerUp(space),
1874
1942
  };
1875
1943
  }
1876
1944
  /** THE FLIP, open-boot edition: `--open` must never boot a credless broker over a root whose
@@ -2340,7 +2408,7 @@ transport, rotateSys = false) {
2340
2408
  if (!auth) {
2341
2409
  auth = await createSpaceAuth(space);
2342
2410
  await putSpaceAuth(store, auth); // strips the $SYS seed at rest, but leaves the in-memory `auth` intact …
2343
- await provisionMembershipCreds(auth, cotalRoot()); // … so the observer can still be minted here (fresh-space only)
2411
+ await provisionMembershipCreds(auth, cotalRoot(), space); // … so the observer can still be minted here (fresh-space only)
2344
2412
  // A fresh space's $SYS material was just minted from the seed that only exists in this branch, so
2345
2413
  // the ASK is already satisfied, so say so rather than rotating a one-second-old account, and never
2346
2414
  // report a rotation that did not happen.
@@ -2376,7 +2444,7 @@ transport, rotateSys = false) {
2376
2444
  console.log(c.dim(" NOTE: full backups taken before this rotation can no longer be restored (they are bound to the retired trust chain) - take a fresh `cotal backup` once the mesh is up."));
2377
2445
  }
2378
2446
  // The DATA half of the membership bundle, on EVERY path — see healMembershipDataCreds.
2379
- await healMembershipDataCreds(auth, cotalRoot());
2447
+ await healMembershipDataCreds(auth, cotalRoot(), space);
2380
2448
  // The $SYS creds must be signed by the system account THIS boot is about to put in `server.conf`.
2381
2449
  // A rotation that committed the trust record and then died leaves them stale, unexpired, and
2382
2450
  // broker-dead and, crash-before-either-write, stale in a way that no comparison between the two
@@ -2389,7 +2457,7 @@ transport, rotateSys = false) {
2389
2457
  // same pair, so booting here would silently downgrade revocation to deny-new for the life of the
2390
2458
  // mesh. The repo's posture is to throw rather than degrade, and the recovery is one command that
2391
2459
  // this message names.
2392
- const stale = staleSystemCreds(cotalRoot(), auth.sys.pub);
2460
+ const stale = staleSystemCreds(cotalRoot(), auth.sys.pub, space);
2393
2461
  if (stale.length)
2394
2462
  throw new Error(`${stale.map((x) => `${x.file} (signed by ${x.iss ? `${x.iss.slice(0, 12)}…` : "an unreadable issuer"})`).join(", ")} ` +
2395
2463
  `${stale.length === 1 ? "is" : "are"} not signed by this space's system account (${auth.sys.pub.slice(0, 12)}…) - ` +
@@ -2528,18 +2596,26 @@ async function assertRootBrokerStopped(root) {
2528
2596
  * its own error text — advice that could not succeed. Healing here fixes both spellings at once:
2529
2597
  * the ordinary `up` repairs the data half with no rotation at all, and a rotation now repairs it
2530
2598
  * too, because this runs after both branches converge. */
2531
- async function healMembershipDataCreds(auth, root) {
2599
+ async function healMembershipDataCreds(auth, root, space) {
2532
2600
  try {
2533
2601
  const store = workspaceSecretStore(root);
2602
+ // THE RESOLVERS, not the bare kinds (P7 §2 rule 1). Both reads below are absent-means-MINT, which
2603
+ // is precisely why the location must be resolved through the choke point: a canonical read on a
2604
+ // root whose material is still flat answers "absent" and mints a SECOND live cred beside the one
2605
+ // the running daemons hold. `up` is a workstation composition by construction, so the resolver
2606
+ // gets the FS arm and moves the material on this first touch.
2607
+ const composition = { injected: false, root };
2608
+ const rwKey = membershipRwCredsKey(space, composition);
2534
2609
  const wrote = [];
2535
- if ((await store.get(MEMBERSHIP_RW_CREDS_KEY)) === undefined) {
2536
- await store.put(MEMBERSHIP_RW_CREDS_KEY, await mintCreds(auth, newIdentity(), "membership-rw"));
2537
- wrote.push(MEMBERSHIP_RW_CREDS_KEY);
2610
+ if ((await store.get(rwKey)) === undefined) {
2611
+ await store.put(rwKey, await mintCreds(auth, newIdentity(), "membership-rw"));
2612
+ wrote.push(MEMBERSHIP_RW_CREDS_KIND); // the KIND is what an operator reads, never the segmented key
2538
2613
  }
2539
- if (!existsSync(cotalPath("membership.json"))) {
2540
- mkSecretDir(cotalPath()); // harden .cotal/ before the file lands, as the fresh path does
2541
- writeSecretFile(cotalPath("membership.json"), JSON.stringify({ accountId: auth.account.pub }));
2542
- wrote.push("membership.json");
2614
+ const configPath = membershipConfigPath(root, space);
2615
+ if (!existsSync(configPath)) {
2616
+ mkSecretDir(dirname(configPath)); // harden the per-space dir before the file lands, as the fresh path does
2617
+ writeSecretFile(configPath, JSON.stringify({ accountId: auth.account.pub }));
2618
+ wrote.push(MEMBERSHIP_CONFIG_KIND);
2543
2619
  }
2544
2620
  if (wrote.length)
2545
2621
  console.log(c.dim(`• membership: provisioned ${wrote.join(" + ")} - the data-account half needs no system-account rotation`));
@@ -2550,7 +2626,7 @@ async function healMembershipDataCreds(auth, root) {
2550
2626
  console.error(c.dim(`• broker-sourced membership not repaired: ${e.message}`));
2551
2627
  }
2552
2628
  }
2553
- async function provisionMembershipCreds(auth, root) {
2629
+ async function provisionMembershipCreds(auth, root, space) {
2554
2630
  try {
2555
2631
  const observer = await mintMembershipObserverCreds(auth, newIdentity());
2556
2632
  const rw = await mintCreds(auth, newIdentity(), "membership-rw");
@@ -2559,11 +2635,19 @@ async function provisionMembershipCreds(auth, root) {
2559
2635
  // close a revoked/removed principal's live connections. A space without it degrades to
2560
2636
  // deny-new-only (durable reauth) — surfaced loudly by the removal path, never silent.
2561
2637
  const evictor = await mintConnectionEvictorCreds(auth, newIdentity());
2562
- mkSecretDir(cotalPath()); // harden .cotal/ before the creds land (born under a private ACL, no race)
2563
- writeSecretFile(cotalPath(SYSTEM_CREDS_FILES[0]), observer);
2564
- await workspaceSecretStore(root).put(MEMBERSHIP_RW_CREDS_KEY, rw); // migrated kind: through the seam (0600 FS put)
2565
- writeSecretFile(cotalPath(SYSTEM_CREDS_FILES[1]), evictor);
2566
- writeSecretFile(cotalPath("membership.json"), JSON.stringify({ accountId: auth.account.pub }));
2638
+ // All four land through the per-kind resolvers (P7 §2 rule 1), and all four through the store's
2639
+ // `put`, which is the same `mkSecretDir` + atomic write the raw `writeSecretFile` did — the
2640
+ // difference being that it hardens the PER-SPACE dir the material now lives in rather than
2641
+ // `.cotal/` itself. This is the fresh-space branch, so nothing is there to migrate; it calls the
2642
+ // resolvers anyway because a kind with two write paths is a kind that grows two layouts.
2643
+ const composition = { injected: false, root };
2644
+ const store = workspaceSecretStore(root);
2645
+ await store.put(membershipObserverCredsKey(space, composition), observer);
2646
+ await store.put(membershipRwCredsKey(space, composition), rw);
2647
+ await store.put(connectionEvictorCredsKey(space, composition), evictor);
2648
+ const configPath = membershipConfigPath(root, space);
2649
+ mkSecretDir(dirname(configPath));
2650
+ writeSecretFile(configPath, JSON.stringify({ accountId: auth.account.pub }));
2567
2651
  }
2568
2652
  catch (e) {
2569
2653
  console.error(c.dim(`• broker-sourced membership not provisioned: ${e.message}`));