@cotal-ai/workspace 0.34.0 → 0.36.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.
@@ -0,0 +1,421 @@
1
+ /**
2
+ * The shared foundation for segmenting root-scoped material per space (P7, then P1) —
3
+ * `docs/design/space-segmentation-p7-p1.md` §2 and §3.
4
+ *
5
+ * Material that is per-tenant in MEANING sits at a root-scoped path today, so a root holds exactly
6
+ * one tenant's copy of it and a sibling tenant silently inherits it. Ending that means writing the
7
+ * owning tenant's name into the location, which raises one question this module answers ONCE for
8
+ * both series: what happens to the roots that already exist.
9
+ *
10
+ * The answer is MOVE ON FIRST TOUCH at a single choke point, not read-fallback, for the reason
11
+ * `migrateLegacyUserAuthState` (`auth-paths.ts:131`) already records: a fallback leaves flows able to
12
+ * read, or worse to `ensure*`-REGENERATE, beside material the old layout still holds. That hazard is
13
+ * sharper here, because this material has absent-means-mint writers (`up.ts:2885`, `up.ts:2889`) — a
14
+ * canonical read on an unmigrated root reads absent and mints a SECOND live cred beside the one the
15
+ * daemons are using.
16
+ *
17
+ * The choke point is {@link migrateLegacyMaterialIn}; the per-kind resolvers built on it are at
18
+ * the bottom of this file, and they are what every consumer of the five P7 kinds calls. Series P1
19
+ * consumes the same choke point from `agent-secrets.ts` — the rules are identical, only the parent
20
+ * directory differs, because §3 places P7's segment under `.cotal/` and P1's under `.cotal/auth/creds/`.
21
+ */
22
+ import { existsSync, readdirSync, renameSync, rmSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { mkSecretDir } from "@cotal-ai/core";
25
+ import { accountInventory, authDir, spaceSegment } from "./auth-paths.js";
26
+ /** `<root>/.cotal` — the dir whose children the segment must never collide with. */
27
+ export function cotalDir(root) {
28
+ return join(root, ".cotal");
29
+ }
30
+ /**
31
+ * The reserved children of `<root>/.cotal/` that the codebase itself writes.
32
+ *
33
+ * {@link spaceSegment}'s collision guarantee was written against the reserved siblings of the AUTH
34
+ * dir. P7 puts a segment directly under `.cotal/`, which is a WIDER namespace, so the guarantee has
35
+ * to hold there too. It does today — no `.cotal` child begins with `space.`, the nearest being
36
+ * `auth-service.<spaceKey>.pid` — but it held by ACCIDENT until something asserted it, which is what
37
+ * `smoke:space-segmentation` now does.
38
+ *
39
+ * Keep in sync with the writers of `.cotal/` children (`cotalPath(...)` in `up.ts`, the raw removal
40
+ * list at `clean.ts:272-279`). A name added here that starts with `space.` is a real collision and
41
+ * the guard suite fails rather than the layout silently aliasing a tenant's segment.
42
+ */
43
+ export const RESERVED_COTAL_CHILDREN = [
44
+ "agents", "auth", "auth-service.json", "broker-policy.json", "channels.json", "config.json",
45
+ "connection-evictor.creds", "delivery.creds", "delivery.log", "delivery.pid", "maintenance",
46
+ "manager.delivery-aware", "manager.log", "manager.pid", "manifests", "membership.json",
47
+ "membership-observer.creds", "membership-rw.creds", "meshes", "nats", "nats.log", "nats.pid",
48
+ "setup.log",
49
+ ];
50
+ /** The delivery daemon's scoped cred — the KIND, i.e. the basename its location ends in and the
51
+ * name every operator-facing string spells. Its store key is `space.<hex>/delivery.creds`, built by
52
+ * {@link deliveryCredsKey}; the bare kind is also the LEGACY root-scoped key this series migrates
53
+ * away from, which is why one constant serves both (a second literal is how the two would drift).
54
+ * Lives in workspace because the key↔filename convention is the workspace layout's; implementations
55
+ * never import each other. */
56
+ export const DELIVERY_CREDS_KIND = "delivery.creds";
57
+ /** The membership feed's data-account rw cred kind — same discipline as {@link DELIVERY_CREDS_KIND}.
58
+ * Named (not a bare literal) so the renewal owner can map a remint result back to the daemon's
59
+ * `membership` component without a hand-copied string. */
60
+ export const MEMBERSHIP_RW_CREDS_KIND = "membership-rw.creds";
61
+ /** The `$SYS` CONNZ observer's kind — the graph feed's read connection and the account-scoped sweep
62
+ * every liveness/eviction verdict is measured against.
63
+ *
64
+ * NOT in `REMINTABLE_DAEMON_CREDS` and never will be: this is `rotation-renewed`, so no persisted
65
+ * seed can re-sign it (the `$SYS` signing seed is discarded at provision). The key exists so a
66
+ * HOSTED composition can inject the cred a system-account rotation minted; it does not make the
67
+ * cred renewable. See `docs/design/u3-membership-sys-injection.md` §2. */
68
+ export const MEMBERSHIP_OBSERVER_CREDS_KIND = "membership-observer.creds";
69
+ /** The `$SYS` KICK-only evictor's kind — the write half of live eviction, paired with
70
+ * {@link MEMBERSHIP_OBSERVER_CREDS_KIND} by one rotation and read per call.
71
+ *
72
+ * Same `rotation-renewed` posture and the same non-membership of `REMINTABLE_DAEMON_CREDS`. Its
73
+ * permission (`$SYS.REQ.SERVER.*.KICK`) carries NO account, so unlike the observer it cannot be
74
+ * tenancy-checked from its own JWT; its containment is that every cid it is handed comes from the
75
+ * observer's account-scoped scan. Keep the two spelled together for that reason. */
76
+ export const CONNECTION_EVICTOR_CREDS_KIND = "connection-evictor.creds";
77
+ /** The DATA account id the CONNZ/event subjects pin — non-secret, but kept 0600 beside the creds.
78
+ * The only P7 kind with no store reader: it is read raw by the eviction path's workstation
79
+ * cross-check, so its resolver returns a PATH ({@link membershipConfigPath}) and has no hosted arm
80
+ * at all. */
81
+ export const MEMBERSHIP_CONFIG_KIND = "membership.json";
82
+ /** The P7 kinds' ROOT-SCOPED locations — the legacy layout this series retires. The two store keys
83
+ * (`membership-rw.creds`, `delivery.creds`) appear as plain names because under the local FS
84
+ * composition a key IS a path under `.cotal/`; see {@link migrateLegacyCotalMaterial} on why the
85
+ * migration is FS-composition-only. `delivery.creds` is here by the §3.2 widening. */
86
+ export const P7_LEGACY_MATERIAL = [
87
+ MEMBERSHIP_OBSERVER_CREDS_KIND, CONNECTION_EVICTOR_CREDS_KIND, MEMBERSHIP_RW_CREDS_KIND,
88
+ MEMBERSHIP_CONFIG_KIND, DELIVERY_CREDS_KIND,
89
+ ];
90
+ /**
91
+ * §2 RULE 4'S PRECONDITION, ASKABLE: would {@link migrateLegacyCotalMaterial} refuse on this root,
92
+ * and why? `undefined` means the root can be shown to hold exactly one tenant.
93
+ *
94
+ * THE ONE IMPLEMENTATION of the rule, exported so a guard that needs to know whether `cotal up` can
95
+ * migrate this root ASKS instead of re-deriving a tenant count. That is commit 2's `repairAdvice`
96
+ * lesson (`sys-creds.ts`) applied where it was still owed: a count is a second implementation of the
97
+ * rule, and it reads "one" on the corrupt-inventory root where this fails CLOSED. A guard built on a
98
+ * count would print a remedy on exactly the root where the remedy refuses.
99
+ *
100
+ * The two callers need it for opposite reasons — the choke point to refuse, the doors below to say
101
+ * truthfully what an operator can do next — and a rule with two implementations drifts at whichever
102
+ * one is not the one someone edits.
103
+ */
104
+ export function spaceMaterialMigrationRefusal(root) {
105
+ const { spaces, corrupt } = accountInventory(authDir(root));
106
+ if (corrupt.length > 0)
107
+ return {
108
+ reason: `this root's tenant list is not fully readable (${corrupt.join(", ")}), so it cannot be shown to hold one space`,
109
+ // Unlike the multi-tenant case this one HAS a remedy, which is why the two are not merged into
110
+ // one refusal: an unreadable record is repairable, an unrecorded owner is not.
111
+ remedy: "Repair or remove those account records first.",
112
+ };
113
+ if (spaces.length > 1)
114
+ return {
115
+ reason: `this root holds ${spaces.length} spaces (${spaces.join(", ")}). ` +
116
+ "The root-scoped copy belongs to whichever tenant booted first and nothing on disk records which, " +
117
+ "so moving it into one tenant's segment would assert an owner that may be wrong",
118
+ remedy: "There is no command to offer here - `cotal up` migrates only on a single-tenant root, and " +
119
+ "`cotal up --rotate-sys` is broker-wide and refuses on this root too. " +
120
+ "Per-space segmentation must land before this material can be reminted here.",
121
+ };
122
+ return undefined;
123
+ }
124
+ /**
125
+ * THE CHOKE POINT (§2 rules 1-4): resolve one kind's per-space location, migrating a legacy
126
+ * root-scoped copy into it on first touch, or REFUSING when the move cannot be made honestly.
127
+ *
128
+ * Returns the canonical path. A caller must obtain the location from here and never build it
129
+ * itself — that is rule 1, and it is what makes "migrate on first touch" reach every flow rather
130
+ * than the ones someone remembered to update.
131
+ *
132
+ * FS COMPOSITION ONLY, and the signature says so rather than the comment alone: this takes a root
133
+ * PATH and no `SecretStore`, so a hosted composition cannot call it. Rule 2's atomicity — the move
134
+ * is one `renameSync`, so a crash leaves each kind wholly legacy or wholly canonical — is a property
135
+ * of the filesystem, and the same move against a hosted store would be a get, put and delete with no
136
+ * atomicity across the three. That is not a gap: a hosted composition provisions these keys
137
+ * externally and re-keys by the coordinated change of §3.1, never by migrating in place. Taking no
138
+ * store makes the unsound call impossible to express, the same reason `rotateSystemCreds` takes none
139
+ * (`system-rotation.ts:88-95`).
140
+ *
141
+ * `parent` is the directory the legacy copy sits DIRECTLY in and under which the segment is created.
142
+ * It is a parameter rather than `cotalDir(root)` because §3 settled two different placements for the
143
+ * two series — P7's segment is a child of `.cotal/`, P1's a child of `.cotal/auth/creds/` — and that
144
+ * was decided in the plan, not discovered by P1. Everything the rules turn on is the same at both
145
+ * placements, so parameterizing the parent is what keeps them ONE implementation; a P1-local copy of
146
+ * rules 2-4 is the second idiom §4 forbids. `root` stays alongside it because rule 4's tenant count
147
+ * is a property of the ROOT's account records, not of whichever directory the material sits in.
148
+ */
149
+ export function migrateLegacyMaterialIn(parent, root, space, kind) {
150
+ const dir = parent;
151
+ const canonical = join(dir, spaceSegment(space), kind);
152
+ const legacyPath = join(dir, kind);
153
+ // Cheap gate first, exactly as the prior art does it: with no legacy copy there is nothing to
154
+ // weigh, so the canonical path is authoritative whatever state it is in, and the tenant-count read
155
+ // below is skipped. A root that never grew past one space, and every root created after this
156
+ // series, take this branch and see no refusal.
157
+ if (!existsSync(legacyPath))
158
+ return canonical;
159
+ // Byte-exact, never `existsSync` alone: on a case-insensitive FS a bare existence check matches a
160
+ // sibling with different case and would migrate a DIFFERENT kind's file.
161
+ let entries;
162
+ try {
163
+ entries = readdirSync(dir, { withFileTypes: true });
164
+ }
165
+ catch (e) {
166
+ if (e.code === "ENOENT")
167
+ return canonical;
168
+ throw e;
169
+ }
170
+ if (!entries.some((e) => e.name === kind))
171
+ return canonical;
172
+ // RULE 4 — refuse to migrate on a root holding more than one space.
173
+ //
174
+ // This is not a duplicate of the `space add` door (§2.1). Migration on a multi-tenant root is
175
+ // WORSE than the defect it ends: the root-scoped copy belongs to whichever tenant booted first and
176
+ // nothing on disk records which that was, so a resolver that migrates it writes it into the
177
+ // segment of the tenant that happens to be BOOTING. That launders an ambient inheritance squat —
178
+ // legible today as a root-scoped file — into a path that ASSERTS an owner that may be wrong. False
179
+ // attribution is the worse end state, and unlike the squat it is irreversible, because the
180
+ // evidence that the attribution was a guess is gone once it is written.
181
+ //
182
+ // It also catches what a door cannot. A door is a check at one moment; roots that were already
183
+ // multi-tenant when this series landed never pass through `space add` again, and a backup of one
184
+ // can be restored at any later date. Both boot straight into this resolver.
185
+ //
186
+ // Fail-CLOSED on an unreadable record, like every other tenant-count read: an under-count here
187
+ // would let the laundering proceed on a root that does hold several tenants.
188
+ const refusal = spaceMaterialMigrationRefusal(root);
189
+ if (refusal)
190
+ throw new Error(`refusing to migrate ${kind} into "${space}"'s per-space segment: ${refusal.reason}. ${refusal.remedy}`);
191
+ // RULE 3 — ambiguity refuses, loudly. Canonical AND legacy both present is a partial migration
192
+ // this cannot arbitrate: canonical existence alone does not prove the migration completed (an
193
+ // empty canonical husk beside real legacy material is a crashed migration, not a finished one).
194
+ if (existsSync(canonical))
195
+ throw new Error(`both the canonical ${canonical} and the legacy ${legacyPath} hold ${kind} for "${space}" - refusing to guess which is current (canonical existence alone does not prove the migration completed). Merge or remove one, then retry.`);
196
+ // RULE 2 — one rename, atomic per kind.
197
+ //
198
+ // The segment dir is created FIRST and hardened, not merely `mkdir`ed: it holds `.creds` material,
199
+ // so it must be born under a private ACL rather than widened afterwards (the same reason
200
+ // `provisionMembershipCreds` hardens `.cotal/` before the creds land, `up.ts:2913`). This is the
201
+ // one way this differs from the prior art it generalizes — `migrateLegacyUserAuthState` renames a
202
+ // dir to a SIBLING dir, so it never has to materialize a parent.
203
+ mkSecretDir(join(dir, spaceSegment(space)));
204
+ renameSync(legacyPath, canonical);
205
+ return canonical;
206
+ }
207
+ /** {@link migrateLegacyMaterialIn} at P7's placement — a legacy copy sitting directly under
208
+ * `<root>/.cotal/`. The five P7 resolvers' one entry point. */
209
+ export function migrateLegacyCotalMaterial(root, space, kind) {
210
+ return migrateLegacyMaterialIn(cotalDir(root), root, space, kind);
211
+ }
212
+ /**
213
+ * THE `space add` DOOR (§2.1): refuse to add a second tenant to a root that still holds unmigrated
214
+ * root-scoped material.
215
+ *
216
+ * Adding a tenant to such a root creates the one state segmentation cannot resolve — legacy material
217
+ * whose owner is unrecorded — and it is the only door that creates it, because `up` cannot mint a
218
+ * second tenant on an established root (`ensureRootForSpace` refuses at `up.ts:2233`). Checking here
219
+ * costs one inventory read in a verb that is already taking the lock and reading the inventory
220
+ * (`per-space-lifecycle.md` §2.1 step 1).
221
+ *
222
+ * This keeps that state from being CREATED. It does not keep it from being ENCOUNTERED — roots
223
+ * already multi-tenant when this series lands, and backups of them, bypass the door entirely. Rule 4
224
+ * of {@link migrateLegacyCotalMaterial} is what catches those. The two are one design and neither is
225
+ * sufficient alone.
226
+ *
227
+ * NOT YET CALLED: `cotal space add` does not exist as a command today (the verb is designed in
228
+ * `per-space-lifecycle.md` §2.1 and not implemented). This is the guarantee it must call when it is
229
+ * built, landed with the foundation so the verb cannot be written without it.
230
+ */
231
+ export function assertNoUnsegmentedLegacyMaterial(root, operation) {
232
+ const present = unsegmentedLegacyMaterial(root);
233
+ if (present.length === 0)
234
+ return;
235
+ throw new Error(`${operation} refuses: this root still holds root-scoped ${present.join(", ")}, which is not keyed to any space. ` +
236
+ "Adding a second tenant now would make that material unattributable - it belongs to the space that booted first, and nothing on disk records which that was. " +
237
+ migrationRemedy(root));
238
+ }
239
+ /** The P7 kinds still sitting at their root-scoped location on this root — the unmigrated set both
240
+ * doors weigh. Named rather than inlined twice because the two doors must agree on what counts. */
241
+ export function unsegmentedLegacyMaterial(root) {
242
+ const dir = cotalDir(root);
243
+ return P7_LEGACY_MATERIAL.filter((kind) => existsSync(join(dir, kind)));
244
+ }
245
+ /**
246
+ * The "what do I do about it" half of both doors' refusals — ASKED of {@link
247
+ * spaceMaterialMigrationRefusal}, never assumed.
248
+ *
249
+ * The obvious line here is "run `cotal up` for the sole tenant once, then retry", and it is right for
250
+ * the population a door usually sees: a root with one tenant, whose material migrates on that root's
251
+ * next first touch. It is WRONG for the population §2.1 says bypasses the doors entirely — roots
252
+ * already multi-tenant when this series landed, and backups of them restored later. There `cotal up`
253
+ * hits rule 4 and refuses, so the sentence hands the operator a command that cannot succeed. That is
254
+ * precisely the defect commit 2 removed from `repairAdvice`, and it was still latent here.
255
+ *
256
+ * So the remedy is composed from the answer rather than from an assumption about who is asking.
257
+ */
258
+ function migrationRemedy(root) {
259
+ const refusal = spaceMaterialMigrationRefusal(root);
260
+ if (!refusal)
261
+ return "Run `cotal up` for the sole tenant once to migrate it into its own segment, then retry.";
262
+ return `Migrating it first is not available on this root either: ${refusal.reason}. ${refusal.remedy}`;
263
+ }
264
+ /**
265
+ * The KEY SHAPE alone — `space.<hex>/<kind>` — resolving nothing and moving nothing.
266
+ *
267
+ * THE ONE SPELLING of the segmented key, so {@link spaceMaterialKey} and the two owners below cannot
268
+ * drift into two layouts. It is exported for the owners that must NOT move material, and there are
269
+ * exactly two kinds of those:
270
+ *
271
+ * - DELETERS. `clean`'s store-seam sweep names the keys it is about to remove; migrating material
272
+ * into the path it will then delete is work done to undo itself, and on the refusal paths (§2
273
+ * rules 3 and 4) it would fail the sweep for material the sweep does not care about.
274
+ * - THE RENEWAL OWNER. {@link REMINTABLE_DAEMON_CREDS}'s `(space) => key` builders (§3.1) feed
275
+ * `remintDaemonCreds`, which has NO absent-means-mint path — its absence case is a loud
276
+ * `skipped: "missing-file"`, never a second cred — and which may hold an INJECTED store while
277
+ * still being handed a workstation `root` it does not own (`manager.ts:870` defaults the store,
278
+ * so `store !== undefined` is not the hosted fact there). The hazard rule 1 exists to stop is not
279
+ * reachable from it, and `up`'s provisioners migrate before any daemon exists to renew.
280
+ *
281
+ * Every other caller wants {@link spaceMaterialKey}: reaching for this one to skip a migration is
282
+ * the read-fallback the design forbids.
283
+ */
284
+ export function segmentedKey(kind, space) {
285
+ return `${spaceSegment(space)}/${kind}`;
286
+ }
287
+ /**
288
+ * THE PER-KIND RESOLVER (§2 rule 1), generic over the kind: the canonical store key for `kind` in
289
+ * `space`, having migrated a legacy root-scoped copy into it first on the FS composition.
290
+ *
291
+ * The named per-kind wrappers below are the surface callers use; this is the one body they share, so
292
+ * "migrate on first touch" cannot exist for four kinds and be forgotten for the fifth. Under the
293
+ * local FS composition the returned key IS the path under `.cotal/` that
294
+ * {@link migrateLegacyCotalMaterial} just moved the material to — the two agree by construction
295
+ * rather than by two spellings of the same layout.
296
+ */
297
+ export function spaceMaterialKey(kind, space, composition) {
298
+ if (!composition.injected)
299
+ migrateLegacyCotalMaterial(composition.root, space, kind);
300
+ return segmentedKey(kind, space);
301
+ }
302
+ /** {@link DELIVERY_CREDS_KIND}'s key for `space` — see {@link spaceMaterialKey}. */
303
+ export function deliveryCredsKey(space, composition) {
304
+ return spaceMaterialKey(DELIVERY_CREDS_KIND, space, composition);
305
+ }
306
+ /** {@link MEMBERSHIP_RW_CREDS_KIND}'s key for `space` — see {@link spaceMaterialKey}. */
307
+ export function membershipRwCredsKey(space, composition) {
308
+ return spaceMaterialKey(MEMBERSHIP_RW_CREDS_KIND, space, composition);
309
+ }
310
+ /** {@link MEMBERSHIP_OBSERVER_CREDS_KIND}'s key for `space` — see {@link spaceMaterialKey}. */
311
+ export function membershipObserverCredsKey(space, composition) {
312
+ return spaceMaterialKey(MEMBERSHIP_OBSERVER_CREDS_KIND, space, composition);
313
+ }
314
+ /** {@link CONNECTION_EVICTOR_CREDS_KIND}'s key for `space` — see {@link spaceMaterialKey}. */
315
+ export function connectionEvictorCredsKey(space, composition) {
316
+ return spaceMaterialKey(CONNECTION_EVICTOR_CREDS_KIND, space, composition);
317
+ }
318
+ /** {@link MEMBERSHIP_CONFIG_KIND}'s PATH for `space`, migrated on first touch.
319
+ *
320
+ * A path and not a key, and it takes a bare `root` rather than a {@link SpaceMaterialComposition},
321
+ * because this kind has no hosted arm to choose between: it is read raw by the eviction path's
322
+ * workstation-only cross-check (`evict-exec.ts:76`) and a hosted composition never has one. */
323
+ export function membershipConfigPath(root, space) {
324
+ return migrateLegacyCotalMaterial(root, space, MEMBERSHIP_CONFIG_KIND);
325
+ }
326
+ /** The per-space area itself, `<root>/.cotal/space.<hex>/` — for the enumerating callers (the
327
+ * `clean` sweep) that remove a tenant's whole segment rather than one kind of it. Resolves NOTHING
328
+ * and migrates NOTHING: a sweeper must not move material it is about to delete. */
329
+ export function spaceMaterialDir(root, space) {
330
+ return join(cotalDir(root), spaceSegment(space));
331
+ }
332
+ /** The P7 kinds a composition holds in its {@link SecretStore} — the ones written through `put` and
333
+ * read back through `get`, so a reap must remove them through the seam and not by unlinking a file.
334
+ * {@link MEMBERSHIP_CONFIG_KIND} is absent: it has no store reader at all (it is read raw by the
335
+ * eviction path's workstation cross-check), so the segment removal is the whole of its reap. */
336
+ const P7_STORE_KINDS = [
337
+ DELIVERY_CREDS_KIND, MEMBERSHIP_RW_CREDS_KIND, MEMBERSHIP_OBSERVER_CREDS_KIND, CONNECTION_EVICTOR_CREDS_KIND,
338
+ ];
339
+ /**
340
+ * `cotal space rm` STEP 1'S PRECONDITION for the step 7 reap (`per-space-lifecycle.md` §2.2), which
341
+ * is deliberately not checked at step 7.
342
+ *
343
+ * THE ORDERING IS THE DESIGN. Step 5 is the point of no return: it deletes the tenant's streams and
344
+ * buckets. Step 7 is the local reap. A precondition discovered at step 7 would refuse AFTER the data
345
+ * is gone and the config re-rendered, leaving the journal entry standing — and since the check would
346
+ * fail identically on every re-run, the removal a crash is supposed to be able to finish could never
347
+ * finish at all. So the question is asked at step 1, beside the inventory read that is already
348
+ * happening, where a refusal costs the operator nothing.
349
+ *
350
+ * WHAT IT REFUSES: a root still holding root-scoped material for any P7 kind. `space rm` runs only on
351
+ * a multi-tenant root (§2.2 step 2 refuses the last tenant), and on such a root that material is
352
+ * unattributable in the §2.1 sense — it belongs to whichever tenant booted first, unrecorded. Reaping
353
+ * around it strands what may be the departing tenant's live `$SYS` pair for a survivor to inherit;
354
+ * reaping it may take a survivor's. There is no third answer, and the honest move is to refuse before
355
+ * anything is destroyed rather than to pick one silently.
356
+ *
357
+ * NOT YET CALLED: `cotal space rm` does not exist as a command today (§2.2 designs it; no `space`
358
+ * verb is implemented). This lands with the material it guards so the verb cannot be written without
359
+ * it, the same reason {@link assertNoUnsegmentedLegacyMaterial} landed with commit 1.
360
+ */
361
+ export function assertSpaceMaterialReapable(root, space, operation) {
362
+ const present = unsegmentedLegacyMaterial(root);
363
+ if (present.length === 0)
364
+ return;
365
+ throw new Error(`${operation} refuses: this root still holds root-scoped ${present.join(", ")}, which is not keyed to any space. ` +
366
+ `Removing "${space}" would either strand that material for a surviving tenant to inherit or delete a surviving tenant's, ` +
367
+ "and nothing on disk records which of them it belongs to. " +
368
+ migrationRemedy(root));
369
+ }
370
+ /**
371
+ * `cotal space rm` STEP 7 (`per-space-lifecycle.md` §2.2): reap ONE tenant's segmented material — the
372
+ * `$SYS` pair that step names, plus the rest of that tenant's segment, which P7 keyed alongside it.
373
+ *
374
+ * IT CANNOT REFUSE, and that is a contract, not an omission. It runs past step 5's point of no
375
+ * return, where a throw would strand the journal entry that gates every other verb on the root; §2.2
376
+ * relies on steps 5 to 7 being individually idempotent so a re-run after a crash FINISHES the
377
+ * removal. So seam failures are returned, not thrown — the same posture, for the same reason, as
378
+ * `remintDaemonCreds` (`renewal.ts`), and the caller must read `failed` or the material silently
379
+ * survives the tenant. Its precondition is {@link assertSpaceMaterialReapable}, asked at step 1.
380
+ *
381
+ * The seam deletes come FIRST and are addressed by {@link segmentedKey}, never by a resolver: a
382
+ * reaper is a DELETER, so it must not move material into the path it is about to remove, and a §2
383
+ * rule 3/4 refusal must not fail a reap over material the reap does not care about. It sweeps every
384
+ * store-backed kind rather than only the two `clean` does, because `clean` is a whole-root reset with
385
+ * a raw sweep of `.cotal/` to fall back on and this is not: for an INJECTED store the segment removal
386
+ * below reaches nothing, so a kind missing from the seam loop would outlive its tenant.
387
+ *
388
+ * It removes only THIS space's segment. A reap spelled `.cotal/space.*` is the shape a reader reaches
389
+ * for after seeing a directory removal, and on the multi-tenant root that is the only root this verb
390
+ * runs on it would take every surviving tenant's live material and report success.
391
+ */
392
+ export async function reapSpaceMaterial(root, space, secrets) {
393
+ const removed = [];
394
+ const failed = [];
395
+ for (const kind of P7_STORE_KINDS) {
396
+ const key = segmentedKey(kind, space);
397
+ try {
398
+ // Idempotent on an absent key by the seam's contract, and the `get` keeps the report honest
399
+ // rather than claiming a removal for material that was never there.
400
+ if ((await secrets.get(key)) !== undefined) {
401
+ await secrets.delete(key);
402
+ removed.push(`.cotal/${key}`);
403
+ }
404
+ }
405
+ catch (e) {
406
+ failed.push(`${key}: ${e instanceof Error ? e.message : String(e)}`);
407
+ }
408
+ }
409
+ const dir = spaceMaterialDir(root, space);
410
+ try {
411
+ if (existsSync(dir)) {
412
+ rmSync(dir, { recursive: true, force: true });
413
+ removed.push(`.cotal/${spaceSegment(space)} (this space's material)`);
414
+ }
415
+ }
416
+ catch (e) {
417
+ failed.push(`${spaceSegment(space)}: ${e instanceof Error ? e.message : String(e)}`);
418
+ }
419
+ return { removed, failed };
420
+ }
421
+ //# sourceMappingURL=space-segmentation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"space-segmentation.js","sourceRoot":"","sources":["../src/space-segmentation.ts"],"names":[],"mappings":"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"}
@@ -25,11 +25,12 @@ import { type SpaceAuth } from "@cotal-ai/core";
25
25
  * account records, which an injected store could neither supply nor be enumerated for. See
26
26
  * {@link rotateSystemCreds}.
27
27
  */
28
- /** The two $SYS credential files, by the same key↔filename convention `renewal.ts` uses. They stay on
29
- * the raw FS, with no secret-store seam: renewing them means rewriting the broker config too, so the
30
- * pair and the trust record move together or not at all, which is not something a store can hold half
31
- * of. Named here so the rotation writer, the staleness check and `cotal clean`'s removal list cannot
32
- * drift apart. */
28
+ /** The two $SYS credential KINDS, in rotation-write order. They stay on the raw FS, with no injected
29
+ * secret-store seam: renewing them means rewriting the broker config too, so the pair and the trust
30
+ * record move together or not at all, which is not something a store can hold half of. Named here so
31
+ * the rotation writer, the staleness check and `cotal clean`'s removal list cannot drift apart —
32
+ * which is also why they are the KIND constants and not a second pair of literals (P7 made the
33
+ * kind and the location two different strings; a literal here would silently stay the old one). */
33
34
  export declare const SYSTEM_CREDS_FILES: readonly ["membership-observer.creds", "connection-evictor.creds"];
34
35
  export interface SystemRotationResult {
35
36
  /** The broker record's new system-account generation (the successor discriminator `putSpaceAuth` guards). */
@@ -85,5 +86,5 @@ export interface StaleSystemCred {
85
86
  * an unreadable file is reported with no `iss` rather than throwing, because a diagnosis surface must not
86
87
  * crash on the corruption it exists to describe.
87
88
  */
88
- export declare function staleSystemCreds(root: string, sysPub: string): StaleSystemCred[];
89
+ export declare function staleSystemCreds(root: string, sysPub: string, space: string): StaleSystemCred[];
89
90
  //# sourceMappingURL=system-rotation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"system-rotation.d.ts","sourceRoot":"","sources":["../src/system-rotation.ts"],"names":[],"mappings":"AAEA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,gBAAgB,CAAC;AAIxB;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH;;;;mBAImB;AACnB,eAAO,MAAM,kBAAkB,oEAAqE,CAAC;AAErG,MAAM,WAAW,oBAAoB;IACnC,6GAA6G;IAC7G,GAAG,EAAE,MAAM,CAAC;IACZ,0GAA0G;IAC1G,IAAI,EAAE,SAAS,CAAC;IAChB,2GAA2G;IAC3G,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAkD1G;AAED,yFAAyF;AACzF,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,2FAA2F;IAC3F,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,eAAe,EAAE,CAehF"}
1
+ {"version":3,"file":"system-rotation.d.ts","sourceRoot":"","sources":["../src/system-rotation.ts"],"names":[],"mappings":"AAEA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,gBAAgB,CAAC;AAWxB;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH;;;;;oGAKoG;AACpG,eAAO,MAAM,kBAAkB,oEAA2E,CAAC;AAE3G,MAAM,WAAW,oBAAoB;IACnC,6GAA6G;IAC7G,GAAG,EAAE,MAAM,CAAC;IACZ,0GAA0G;IAC1G,IAAI,EAAE,SAAS,CAAC;IAChB,2GAA2G;IAC3G,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAyD1G;AAED,yFAAyF;AACzF,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,2FAA2F;IAC3F,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,eAAe,EAAE,CAqB/F"}
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { credsClaims, mintConnectionEvictorCreds, mintMembershipObserverCreds, newIdentity, rotateSystemAccount, writeSecretFileAtomic, } from "@cotal-ai/core";
4
4
  import { assertSingleSpaceBroker, authDir, getSpaceAuth, putSpaceAuth } from "./auth-paths.js";
5
5
  import { workspaceSecretStore } from "./secret-store-fs.js";
6
+ import { connectionEvictorCredsKey, CONNECTION_EVICTOR_CREDS_KIND, membershipObserverCredsKey, MEMBERSHIP_OBSERVER_CREDS_KIND, migrateLegacyCotalMaterial, } from "./space-segmentation.js";
6
7
  /**
7
8
  * The class-3 ($SYS) renewal owner's half, the counterpart to `renewal.ts`, which owns the class-2
8
9
  * standing renewal and deliberately EXCLUDES these two files.
@@ -29,12 +30,13 @@ import { workspaceSecretStore } from "./secret-store-fs.js";
29
30
  * account records, which an injected store could neither supply nor be enumerated for. See
30
31
  * {@link rotateSystemCreds}.
31
32
  */
32
- /** The two $SYS credential files, by the same key↔filename convention `renewal.ts` uses. They stay on
33
- * the raw FS, with no secret-store seam: renewing them means rewriting the broker config too, so the
34
- * pair and the trust record move together or not at all, which is not something a store can hold half
35
- * of. Named here so the rotation writer, the staleness check and `cotal clean`'s removal list cannot
36
- * drift apart. */
37
- export const SYSTEM_CREDS_FILES = ["membership-observer.creds", "connection-evictor.creds"];
33
+ /** The two $SYS credential KINDS, in rotation-write order. They stay on the raw FS, with no injected
34
+ * secret-store seam: renewing them means rewriting the broker config too, so the pair and the trust
35
+ * record move together or not at all, which is not something a store can hold half of. Named here so
36
+ * the rotation writer, the staleness check and `cotal clean`'s removal list cannot drift apart —
37
+ * which is also why they are the KIND constants and not a second pair of literals (P7 made the
38
+ * kind and the location two different strings; a literal here would silently stay the old one). */
39
+ export const SYSTEM_CREDS_FILES = [MEMBERSHIP_OBSERVER_CREDS_KIND, CONNECTION_EVICTOR_CREDS_KIND];
38
40
  /**
39
41
  * Rotate the space's system account and re-mint both $SYS creds against it.
40
42
  *
@@ -98,9 +100,16 @@ export async function rotateSystemCreds(root, expectedSpace) {
98
100
  // plus an eviction rail whose recovery is one re-run.
99
101
  //
100
102
  // Each file is still written atomically, so no reader can ever see a half-written credential.
103
+ //
104
+ // Written through the per-kind resolvers (P7 §2 rule 1) rather than a hand-composed
105
+ // `join(root, ".cotal", …)`: the pair is per-SPACE now, and this is a workstation-only operation,
106
+ // so the composition is the FS one by construction. The store's `put` is the same
107
+ // `mkSecretDir` + `writeSecretFileAtomic` the raw write used, and it is what creates the
108
+ // `space.<hex>/` dir on a root whose pair has never been segmented.
109
+ const composition = { injected: false, root };
101
110
  await putSpaceAuth(s, rotated);
102
- writeSecretFileAtomic(join(root, ".cotal", SYSTEM_CREDS_FILES[0]), observer);
103
- writeSecretFileAtomic(join(root, ".cotal", SYSTEM_CREDS_FILES[1]), evictor);
111
+ await s.put(membershipObserverCredsKey(expectedSpace, composition), observer);
112
+ await s.put(connectionEvictorCredsKey(expectedSpace, composition), evictor);
104
113
  return { gen: rotated.gen ?? 0, auth: rotated, expiresAt: credsClaims(observer).exp };
105
114
  }
106
115
  /**
@@ -120,10 +129,16 @@ export async function rotateSystemCreds(root, expectedSpace) {
120
129
  * an unreadable file is reported with no `iss` rather than throwing, because a diagnosis surface must not
121
130
  * crash on the corruption it exists to describe.
122
131
  */
123
- export function staleSystemCreds(root, sysPub) {
132
+ export function staleSystemCreds(root, sysPub, space) {
124
133
  const stale = [];
125
134
  for (const file of SYSTEM_CREDS_FILES) {
126
- const path = join(root, ".cotal", file);
135
+ // The CHOKE POINT, called for its path (P7 §2 rule 1). A reader, not a writer — but it must not
136
+ // read the canonical location without moving a legacy pair into it first: reading past an
137
+ // unmigrated pair would answer "absent, therefore not stale" for the exact half-rotated state
138
+ // this function exists to catch, and both surfaces that call it (the boot path, `doctor auth`)
139
+ // are the first thing to touch these creds on a root `up` has not re-provisioned. A §2 rule 3/4
140
+ // refusal propagates, loudly, rather than being swallowed into a healthy-looking answer.
141
+ const path = migrateLegacyCotalMaterial(root, space, file);
127
142
  if (!existsSync(path))
128
143
  continue;
129
144
  let iss;
@@ -1 +1 @@
1
- {"version":3,"file":"system-rotation.js","sourceRoot":"","sources":["../src/system-rotation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EACL,WAAW,EACX,0BAA0B,EAC1B,2BAA2B,EAC3B,WAAW,EACX,mBAAmB,EACnB,qBAAqB,GAEtB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,uBAAuB,EAAE,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/F,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAE5D;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH;;;;mBAImB;AACnB,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,2BAA2B,EAAE,0BAA0B,CAAU,CAAC;AAWrG;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,IAAY,EAAE,aAAqB;IACzE,gGAAgG;IAChG,8FAA8F;IAC9F,kGAAkG;IAClG,kGAAkG;IAClG,0FAA0F;IAC1F,0DAA0D;IAC1D,EAAE;IACF,yFAAyF;IACzF,gGAAgG;IAChG,gGAAgG;IAChG,8FAA8F;IAC9F,mGAAmG;IACnG,gGAAgG;IAChG,kGAAkG;IAClG,oFAAoF;IACpF,uBAAuB,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,qDAAqD,CAAC,CAAC;IAC9F,MAAM,CAAC,GAAG,oBAAoB,CAAC,IAAI,CAAC,CAAC;IACrC,MAAM,IAAI,GAAG,MAAM,YAAY,CAAC,CAAC,EAAE,aAAa,CAAC,CAAC;IAClD,IAAI,CAAC,IAAI;QACP,MAAM,IAAI,KAAK,CAAC,iDAAiD,aAAa,0CAA0C,CAAC,CAAC;IAC5H,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI;QACrB,MAAM,IAAI,KAAK,CACb,iLAAiL,CAClL,CAAC;IAEJ,0FAA0F;IAC1F,MAAM,OAAO,GAAG,MAAM,mBAAmB,CAAC,IAAI,CAAC,CAAC;IAChD,MAAM,QAAQ,GAAG,MAAM,2BAA2B,CAAC,OAAO,EAAE,WAAW,EAAE,CAAC,CAAC;IAC3E,MAAM,OAAO,GAAG,MAAM,0BAA0B,CAAC,OAAO,EAAE,WAAW,EAAE,CAAC,CAAC;IAEzE,mGAAmG;IACnG,8DAA8D;IAC9D,EAAE;IACF,oGAAoG;IACpG,kGAAkG;IAClG,+FAA+F;IAC/F,iGAAiG;IACjG,iGAAiG;IACjG,iGAAiG;IACjG,mGAAmG;IACnG,mGAAmG;IACnG,sDAAsD;IACtD,EAAE;IACF,8FAA8F;IAC9F,MAAM,YAAY,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;IAC/B,qBAAqB,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,kBAAkB,CAAC,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;IAC7E,qBAAqB,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,kBAAkB,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;IAE5E,OAAO,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAC;AACxF,CAAC;AASD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAY,EAAE,MAAc;IAC3D,MAAM,KAAK,GAAsB,EAAE,CAAC;IACpC,KAAK,MAAM,IAAI,IAAI,kBAAkB,EAAE,CAAC;QACtC,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,CAAC,CAAC;QACxC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,SAAS;QAChC,IAAI,GAAuB,CAAC;QAC5B,IAAI,CAAC;YACH,GAAG,GAAG,WAAW,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC;QACpD,CAAC;QAAC,MAAM,CAAC;YACP,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,gEAAgE;YACtF,SAAS;QACX,CAAC;QACD,IAAI,GAAG,KAAK,MAAM;YAAE,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC;IAChD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
1
+ {"version":3,"file":"system-rotation.js","sourceRoot":"","sources":["../src/system-rotation.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cotal-ai/workspace",
3
3
  "description": "Cotal machine-local workstation layer: the ~/.cotal mesh registry, target resolution, preflight, on-disk auth-path I/O, and the command-copy renderer — operator concerns over a local checkout, kept separate from the wire protocol in @cotal-ai/core.",
4
- "version": "0.34.0",
4
+ "version": "0.36.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -18,7 +18,7 @@
18
18
  }
19
19
  },
20
20
  "dependencies": {
21
- "@cotal-ai/core": "0.34.0"
21
+ "@cotal-ai/core": "0.36.0"
22
22
  },
23
23
  "files": [
24
24
  "dist"