@papillon6814/copi 0.1.0 → 0.2.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/LICENSE.unicode +39 -0
  2. package/README.md +57 -5
  3. package/dist/native/rename-exclusive-arm64 +0 -0
  4. package/dist/native/rename-exclusive-arm64.manifest.json +12 -0
  5. package/dist/src/apply.d.ts +9 -0
  6. package/dist/src/apply.js +110 -9
  7. package/dist/src/catalog.d.ts +21 -4
  8. package/dist/src/catalog.js +265 -133
  9. package/dist/src/cli.d.ts +2 -1
  10. package/dist/src/cli.js +73 -3
  11. package/dist/src/groups.js +8 -3
  12. package/dist/src/inspect.d.ts +3 -1
  13. package/dist/src/inspect.js +16 -6
  14. package/dist/src/instruction-reference.d.ts +6 -0
  15. package/dist/src/instruction-reference.js +21 -0
  16. package/dist/src/manage.d.ts +2 -0
  17. package/dist/src/manage.js +329 -0
  18. package/dist/src/migrate.d.ts +25 -0
  19. package/dist/src/migrate.js +171 -0
  20. package/dist/src/migration-backup.d.ts +22 -0
  21. package/dist/src/migration-backup.js +403 -0
  22. package/dist/src/migration-inventory.d.ts +9 -0
  23. package/dist/src/migration-inventory.js +237 -0
  24. package/dist/src/migration-resolution.d.ts +28 -0
  25. package/dist/src/migration-resolution.js +1618 -0
  26. package/dist/src/migration.d.ts +49 -0
  27. package/dist/src/migration.js +1004 -0
  28. package/dist/src/names.d.ts +11 -0
  29. package/dist/src/names.js +40 -0
  30. package/dist/src/native-placement.d.ts +16 -0
  31. package/dist/src/native-placement.js +557 -0
  32. package/dist/src/refresh.d.ts +20 -0
  33. package/dist/src/refresh.js +143 -0
  34. package/dist/src/resource-mutations.d.ts +45 -0
  35. package/dist/src/resource-mutations.js +225 -0
  36. package/dist/src/source-content.d.ts +4 -0
  37. package/dist/src/source-content.js +208 -0
  38. package/dist/src/storage-journal.d.ts +111 -0
  39. package/dist/src/storage-journal.js +613 -0
  40. package/dist/src/storage-root-lease.d.ts +7 -0
  41. package/dist/src/storage-root-lease.js +92 -0
  42. package/dist/src/storage-transaction.d.ts +100 -0
  43. package/dist/src/storage-transaction.js +3951 -0
  44. package/dist/src/terminal-editor.d.ts +8 -0
  45. package/dist/src/terminal-editor.js +391 -0
  46. package/dist/src/types.d.ts +18 -2
  47. package/dist/src/unicode-case-folding-data.d.ts +1 -0
  48. package/dist/src/unicode-case-folding-data.js +1596 -0
  49. package/dist/src/unicode-case-folding.d.ts +2 -0
  50. package/dist/src/unicode-case-folding.js +9 -0
  51. package/dist/src/wizard.d.ts +11 -3
  52. package/dist/src/wizard.js +274 -18
  53. package/package.json +5 -3
@@ -0,0 +1,1618 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { chmod, link, lstat, mkdir, open, readdir, realpath, rename, rmdir, unlink } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { types as utilTypes } from "node:util";
7
+ import { createTwoFilesPatch } from "diff";
8
+ import { inspectFixedResource, maskSensitiveText } from "./inspect.js";
9
+ import { parseStoredCatalog } from "./catalog.js";
10
+ import { parseMigrationBackupManifest, verifyMigrationBackup } from "./migration-backup.js";
11
+ import { isManagedMigrationRootName, listManagedMigrationRootEntries, projectMigrationInventory, scanMigrationInventory } from "./migration-inventory.js";
12
+ import { acquireRootLease } from "./storage-root-lease.js";
13
+ import { canonicalStorageJournalJson, parseMigrationStartInventory, parseStorageJournal, validateLegacyMigrationJournalTargets, } from "./storage-journal.js";
14
+ const lockName = ".copi-transaction";
15
+ const claimName = ".copi-transaction-recovery";
16
+ const recordName = ".copi-migration-resolution.json";
17
+ const resolutionTempPrefix = ".copi-migration-resolution.";
18
+ const excludedNames = new Set([
19
+ ".git", ".cache", ".npm", "auth", "node_modules", "cache", "caches", "history", "sessions",
20
+ "auth.json", "mcp-auth.json", "oauth.json",
21
+ ]);
22
+ const sha256Pattern = /^[0-9a-f]{64}$/;
23
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
24
+ const uint64Pattern = /^(0|[1-9][0-9]*)$/;
25
+ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
26
+ const compareUtf8 = (left, right) => Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8"));
27
+ let resolutionCheckpoint;
28
+ /** @internal A deterministic crash seam for subprocess checkpoint tests. */
29
+ export function setMigrationResolutionCheckpointForTests(callback) {
30
+ resolutionCheckpoint = callback;
31
+ }
32
+ const preparedStates = new WeakMap();
33
+ function canonicalJson(value) {
34
+ if (Array.isArray(value))
35
+ return `[${value.map(canonicalJson).join(",")}]`;
36
+ if (value && typeof value === "object") {
37
+ const record = value;
38
+ const keys = Object.keys(record).sort(compareUtf8);
39
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
40
+ }
41
+ const text = JSON.stringify(value);
42
+ if (text === undefined)
43
+ throw new Error("Migration resolution record contains a non-JSON value.");
44
+ return text;
45
+ }
46
+ function isRecord(value) {
47
+ return typeof value === "object" && value !== null && !Array.isArray(value);
48
+ }
49
+ function exactKeys(value, keys) {
50
+ if (!isRecord(value) || utilTypes.isProxy(value))
51
+ return false;
52
+ try {
53
+ const prototype = Object.getPrototypeOf(value);
54
+ if (prototype !== Object.prototype && prototype !== null)
55
+ return false;
56
+ const own = Reflect.ownKeys(value);
57
+ return own.length === keys.length && own.every((key) => typeof key === "string" && keys.includes(key)) &&
58
+ keys.every((key) => {
59
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
60
+ return !!descriptor && Object.hasOwn(descriptor, "value") && descriptor.enumerable === true;
61
+ });
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ function denseArray(value) {
68
+ if (!Array.isArray(value) || utilTypes.isProxy(value))
69
+ return false;
70
+ try {
71
+ const keys = Reflect.ownKeys(value);
72
+ const length = Object.getOwnPropertyDescriptor(value, "length");
73
+ if (!length || !Object.hasOwn(length, "value") || length.enumerable || !Number.isSafeInteger(length.value) || length.value < 0 || keys.length !== length.value + 1)
74
+ return false;
75
+ return keys.every((key) => key === "length" || typeof key === "string" && /^(0|[1-9][0-9]*)$/.test(key) && Number(key) < length.value &&
76
+ (() => { const descriptor = Object.getOwnPropertyDescriptor(value, key); return !!descriptor && Object.hasOwn(descriptor, "value") && descriptor.enumerable; })());
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ function validU64(value) {
83
+ if (typeof value !== "string" || !uint64Pattern.test(value))
84
+ return false;
85
+ try {
86
+ return BigInt(value) <= 18446744073709551615n;
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
92
+ function validPath(value) {
93
+ if (typeof value !== "string" || value.includes("\\") || value.includes("\0") || path.posix.isAbsolute(value) ||
94
+ Buffer.from(value, "utf8").toString("utf8") !== value)
95
+ return false;
96
+ if (value === ".")
97
+ return true;
98
+ return value.length > 0 && path.posix.normalize(value) === value && value.split("/").every((part) => part !== "" && part !== "." && part !== "..");
99
+ }
100
+ function parseFileProof(value) {
101
+ if (!exactKeys(value, ["kind", "mode", "dev", "ino", "bytesHash"]) || value.kind !== "file" ||
102
+ typeof value.mode !== "number" || !Number.isInteger(value.mode) || value.mode < 0 || value.mode > 0o7777 ||
103
+ !validU64(value.dev) || !validU64(value.ino) || typeof value.bytesHash !== "string" || !sha256Pattern.test(value.bytesHash)) {
104
+ throw new Error("Invalid migration resolution file proof.");
105
+ }
106
+ return { kind: "file", mode: value.mode, dev: value.dev, ino: value.ino, bytesHash: value.bytesHash };
107
+ }
108
+ function parseDirectoryProof(value) {
109
+ if (!exactKeys(value, ["kind", "mode", "dev", "ino"]) || value.kind !== "directory" ||
110
+ typeof value.mode !== "number" || !Number.isInteger(value.mode) || value.mode < 0 || value.mode > 0o7777 ||
111
+ !validU64(value.dev) || !validU64(value.ino))
112
+ throw new Error("Invalid migration resolution directory proof.");
113
+ return { kind: "directory", mode: value.mode, dev: value.dev, ino: value.ino };
114
+ }
115
+ function parseArtifact(value, index) {
116
+ if (!exactKeys(value, ["name", "kind", "mode", "dev", "ino", "hash"]) || typeof value.name !== "string" ||
117
+ typeof value.kind !== "string" || !["file", "directory"].includes(value.kind) ||
118
+ typeof value.mode !== "number" || !Number.isInteger(value.mode) || value.mode < 0 || value.mode > 0o7777 ||
119
+ !validU64(value.dev) || !validU64(value.ino) || typeof value.hash !== "string" || !sha256Pattern.test(value.hash)) {
120
+ throw new Error("Invalid migration resolution cleanup artifact proof.");
121
+ }
122
+ const required = [
123
+ [".copi-transaction/held-catalog", "file"], [".copi-transaction/old-catalog", "file"],
124
+ [".copi-transaction/next-catalog", "file"], [".copi-transaction/journal.digest", "file"],
125
+ [".copi-transaction/journal.json", "file"], [".copi-transaction/owner.json", "file"],
126
+ [".copi-transaction", "directory"], [".copi-transaction-recovery/owner.json", "file"],
127
+ [".copi-transaction-recovery", "directory"],
128
+ ];
129
+ const item = required[index];
130
+ if (!item || value.name !== item[0] || value.kind !== item[1])
131
+ throw new Error("Migration resolution cleanup artifacts are not the closed ordered allowlist.");
132
+ return { name: value.name, kind: value.kind, mode: value.mode, dev: value.dev, ino: value.ino, hash: value.hash };
133
+ }
134
+ function progressPhase(completed, pending) {
135
+ if (completed === 9)
136
+ return "terminal";
137
+ const index = pending ?? completed;
138
+ if (index <= 2)
139
+ return "artifacts";
140
+ if (index === 3 || index === 4)
141
+ return "journal";
142
+ if (index === 5)
143
+ return "owner";
144
+ if (index === 6)
145
+ return "lock";
146
+ if (index === 7)
147
+ return "claim-owner";
148
+ return "claim";
149
+ }
150
+ function parseProgress(value) {
151
+ if (!exactKeys(value, ["phase", "completed", "pending"]) || typeof value.completed !== "number" || !Number.isSafeInteger(value.completed) ||
152
+ value.completed < 0 || value.completed > 9 || !(value.pending === null || value.pending === value.completed) ||
153
+ (value.completed === 9 && value.pending !== null) ||
154
+ !(value.phase === "artifacts" || value.phase === "journal" || value.phase === "owner" || value.phase === "lock" ||
155
+ value.phase === "claim-owner" || value.phase === "claim" || value.phase === "terminal") || value.phase !== progressPhase(value.completed, value.pending)) {
156
+ throw new Error("Invalid migration resolution write-ahead progress.");
157
+ }
158
+ return { phase: value.phase, completed: value.completed, pending: value.pending };
159
+ }
160
+ function parseEncodedFile(value) {
161
+ if (!exactKeys(value, ["rawBase64", "rawHash", "file"]) || typeof value.rawBase64 !== "string" ||
162
+ typeof value.rawHash !== "string" || !sha256Pattern.test(value.rawHash))
163
+ throw new Error("Invalid encoded migration resolution source proof.");
164
+ const bytes = Buffer.from(value.rawBase64, "base64");
165
+ if (bytes.toString("base64") !== value.rawBase64 || sha256(bytes) !== value.rawHash)
166
+ throw new Error("Migration resolution source bytes do not match their canonical base64 hash.");
167
+ const file = parseFileProof(value.file);
168
+ if (file.bytesHash !== value.rawHash)
169
+ throw new Error("Migration resolution source file proof does not match its raw bytes.");
170
+ return { rawBase64: value.rawBase64, rawHash: value.rawHash, file };
171
+ }
172
+ function parseBackupInventory(value, backupDir) {
173
+ if (!exactKeys(value, ["version", "nodes", "hash"]) || value.version !== 1 || !denseArray(value.nodes) ||
174
+ typeof value.hash !== "string" || !sha256Pattern.test(value.hash))
175
+ throw new Error("Invalid migration resolution backup inventory envelope.");
176
+ const nodes = [];
177
+ const seenPaths = new Set();
178
+ const seenIdentities = new Set();
179
+ let previousPath;
180
+ for (const entry of value.nodes) {
181
+ if (!exactKeys(entry, ["path", "kind", "mode", "dev", "ino", "bytesHash"]) || !validPath(entry.path) ||
182
+ (entry.kind !== "file" && entry.kind !== "directory") || typeof entry.mode !== "number" || !Number.isInteger(entry.mode) ||
183
+ entry.mode < 0 || entry.mode > 0o7777 || !validU64(entry.dev) || !validU64(entry.ino) ||
184
+ typeof entry.bytesHash !== "string" || !sha256Pattern.test(entry.bytesHash) ||
185
+ (entry.path !== ".migration-backups" && entry.path !== backupDir && !entry.path.startsWith(`${backupDir}/`))) {
186
+ throw new Error("Invalid or out-of-scope migration resolution backup inventory node.");
187
+ }
188
+ if (previousPath !== undefined && compareUtf8(previousPath, entry.path) >= 0) {
189
+ throw new Error("Migration resolution backup inventory nodes are not in canonical path order.");
190
+ }
191
+ const identity = `${entry.dev}:${entry.ino}`;
192
+ if (seenPaths.has(entry.path) || seenIdentities.has(identity))
193
+ throw new Error("Migration resolution backup inventory contains a duplicate path or hardlink identity.");
194
+ seenPaths.add(entry.path);
195
+ seenIdentities.add(identity);
196
+ previousPath = entry.path;
197
+ nodes.push({ path: entry.path, kind: entry.kind, mode: entry.mode, dev: entry.dev, ino: entry.ino, bytesHash: entry.bytesHash });
198
+ }
199
+ const byPath = new Map(nodes.map((node) => [node.path, node]));
200
+ const parent = byPath.get(".migration-backups");
201
+ const backupRoot = byPath.get(backupDir);
202
+ if (!parent || parent.kind !== "directory" || !backupRoot || backupRoot.kind !== "directory") {
203
+ throw new Error("Migration resolution backup inventory omits its rooted parent or owned UUID directory.");
204
+ }
205
+ const children = new Map();
206
+ for (const node of nodes) {
207
+ if (node.path === ".migration-backups")
208
+ continue;
209
+ const parentPath = path.posix.dirname(node.path);
210
+ const parentNode = byPath.get(parentPath);
211
+ if (!parentNode || parentNode.kind !== "directory")
212
+ throw new Error("Migration resolution backup inventory node has no recorded directory parent.");
213
+ const listing = children.get(parentPath) ?? [];
214
+ listing.push([path.posix.basename(node.path), node.kind]);
215
+ children.set(parentPath, listing);
216
+ }
217
+ for (const node of nodes) {
218
+ if (node.kind !== "directory")
219
+ continue;
220
+ const listing = children.get(node.path) ?? [];
221
+ listing.sort(([left], [right]) => compareUtf8(left, right));
222
+ const expected = sha256(Buffer.concat([
223
+ Buffer.from("copi-directory-v1\0", "utf8"),
224
+ Buffer.from(canonicalJson(listing), "utf8"),
225
+ ]));
226
+ if (node.bytesHash !== expected)
227
+ throw new Error(`Migration resolution backup directory listing commitment is invalid for ${node.path}.`);
228
+ }
229
+ const hash = sha256(Buffer.concat([
230
+ Buffer.from("copi-inventory-v1\0", "utf8"),
231
+ Buffer.from(canonicalJson(nodes), "utf8"),
232
+ ]));
233
+ if (value.hash !== hash)
234
+ throw new Error("Migration resolution backup inventory hash does not match its canonical nodes.");
235
+ return { version: 1, nodes, hash };
236
+ }
237
+ function parseClaimOwner(value, expected) {
238
+ if (!exactKeys(value, ["version", "mode", "root", "token", "resolutionId", "nonce", "host", "pid", "lock", "journalRawHash"]) ||
239
+ value.version !== 1 || value.mode !== "legacy-migration-resolution-claim" || value.root !== expected.root || value.token !== expected.token ||
240
+ value.resolutionId !== expected.resolutionId || typeof value.nonce !== "string" || !uuidPattern.test(value.nonce) ||
241
+ typeof value.host !== "string" || value.host.length === 0 || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid < 1 ||
242
+ value.journalRawHash !== expected.journalRawHash)
243
+ throw new Error("Invalid typed migration resolution claim owner.");
244
+ return { version: 1, mode: "legacy-migration-resolution-claim", root: value.root, token: value.token,
245
+ resolutionId: value.resolutionId, nonce: value.nonce, host: value.host, pid: value.pid,
246
+ lock: parseDirectoryProof(value.lock), journalRawHash: value.journalRawHash };
247
+ }
248
+ function parseRecord(value) {
249
+ const keys = ["version", "mode", "root", "token", "resolutionId", "generation", "predecessorHash", "originalJournal", "oldCatalog", "newCatalog",
250
+ "currentCatalog", "backupDir", "backupManifest", "backupInventory", "reviewId", "privateRevisionHash", "confirmed", "sensitivePaths",
251
+ "approvedInventory", "originalOwner", "lock", "claim", "cleanupArtifacts", "progress"];
252
+ if (!exactKeys(value, keys) || value.version !== 1 || value.mode !== "legacy-migration-resolution" || typeof value.root !== "string" ||
253
+ !path.isAbsolute(value.root) || path.resolve(value.root) !== value.root || typeof value.token !== "string" || !uuidPattern.test(value.token) ||
254
+ typeof value.resolutionId !== "string" || !uuidPattern.test(value.resolutionId) || typeof value.generation !== "number" ||
255
+ !Number.isSafeInteger(value.generation) || value.generation < 1 ||
256
+ !(value.predecessorHash === null || typeof value.predecessorHash === "string" && sha256Pattern.test(value.predecessorHash)) ||
257
+ (value.generation === 1 ? value.predecessorHash !== null : value.predecessorHash === null) ||
258
+ typeof value.oldCatalog !== "string" || !sha256Pattern.test(value.oldCatalog) || typeof value.newCatalog !== "string" || !sha256Pattern.test(value.newCatalog) ||
259
+ typeof value.backupDir !== "string" || !/^\.migration-backups\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value.backupDir) ||
260
+ typeof value.backupManifest !== "string" || !sha256Pattern.test(value.backupManifest) || typeof value.reviewId !== "string" || !uuidPattern.test(value.reviewId) ||
261
+ typeof value.privateRevisionHash !== "string" || !sha256Pattern.test(value.privateRevisionHash) || value.confirmed !== true || !denseArray(value.sensitivePaths) ||
262
+ value.sensitivePaths.some((entry) => typeof entry !== "string") || typeof value.cleanupArtifacts !== "object" || !denseArray(value.cleanupArtifacts) ||
263
+ value.cleanupArtifacts.length !== 9 || !exactKeys(value.claim, ["directory", "owner", "ownerRawBase64"]) || typeof value.claim.ownerRawBase64 !== "string") {
264
+ throw new Error("Invalid migration resolution record envelope or exact keys.");
265
+ }
266
+ const originalJournal = parseEncodedFile(value.originalJournal);
267
+ const originalOwner = parseEncodedFile(value.originalOwner);
268
+ const currentCatalog = parseFileProof(value.currentCatalog);
269
+ const lock = parseDirectoryProof(value.lock);
270
+ const claim = {
271
+ directory: parseDirectoryProof(value.claim.directory),
272
+ owner: parseFileProof(value.claim.owner),
273
+ ownerRawBase64: value.claim.ownerRawBase64,
274
+ };
275
+ const claimBytes = Buffer.from(claim.ownerRawBase64, "base64");
276
+ if (claimBytes.toString("base64") !== claim.ownerRawBase64 || sha256(claimBytes) !== claim.owner.bytesHash)
277
+ throw new Error("Typed claim owner bytes do not match their fixed proof.");
278
+ const backupInventory = parseBackupInventory(value.backupInventory, value.backupDir);
279
+ const approvedInventory = parseMigrationStartInventory(value.approvedInventory);
280
+ const sensitivePaths = value.sensitivePaths;
281
+ if (sensitivePaths.some((entry, index) => !entry || entry.includes("\\") || entry.includes("\0") ||
282
+ (index > 0 && compareUtf8(sensitivePaths[index - 1], entry) >= 0)))
283
+ throw new Error("Migration resolution sensitive paths are not a canonical unique set.");
284
+ const cleanupArtifacts = value.cleanupArtifacts.map((artifact, index) => parseArtifact(artifact, index));
285
+ const progress = parseProgress(value.progress);
286
+ const raw = Buffer.from(originalJournal.rawBase64, "base64");
287
+ const journalValue = JSON.parse(raw.toString("utf8"));
288
+ const journal = parseStorageJournal(journalValue, { root: value.root, token: value.token });
289
+ if (journal.version !== 5 || raw.toString("utf8") !== canonicalStorageJournalJson(journal) || journal.oldCatalog !== value.oldCatalog ||
290
+ journal.newCatalog !== value.newCatalog || journal.backupDir !== value.backupDir || journal.backupManifest !== value.backupManifest) {
291
+ throw new Error("Resolution record does not retain its exact original canonical v5 journal authority.");
292
+ }
293
+ const backupDir = value.backupDir;
294
+ const backupRootNode = inventoryNode(backupInventory, backupDir);
295
+ const backupManifestNode = inventoryNode(backupInventory, `${backupDir}/manifest.json`);
296
+ const backupParentNode = inventoryNode(backupInventory, ".migration-backups");
297
+ const startBackupParent = inventoryNode(journal.startInventory, ".migration-backups");
298
+ const approvedBackupParent = inventoryNode(approvedInventory, ".migration-backups");
299
+ const expectedBackupParentHash = sha256(Buffer.concat([
300
+ Buffer.from("copi-directory-v1\0", "utf8"),
301
+ Buffer.from(canonicalJson([[path.posix.basename(backupDir), "directory"]]), "utf8"),
302
+ ]));
303
+ const backupInventoryIsScoped = backupInventory.nodes.every((node) => node.path === ".migration-backups" ||
304
+ node.path === backupDir || node.path.startsWith(`${backupDir}/`));
305
+ const approvedInventoryContainsBackup = approvedInventory.nodes.some((node) => node.path === backupDir || node.path.startsWith(`${backupDir}/`));
306
+ const baselineParentIsPreserved = startBackupParent !== undefined && startBackupParent.kind === "directory" &&
307
+ approvedBackupParent !== undefined && canonicalJson(approvedBackupParent) === canonicalJson(startBackupParent) &&
308
+ backupParentNode !== undefined && backupParentNode.kind === "directory" &&
309
+ approvedBackupParent.dev === backupParentNode.dev && approvedBackupParent.ino === backupParentNode.ino &&
310
+ approvedBackupParent.mode === backupParentNode.mode;
311
+ if (!backupInventoryIsScoped || approvedInventoryContainsBackup || !backupParentNode || backupParentNode.kind !== "directory" ||
312
+ backupParentNode.bytesHash !== expectedBackupParentHash || !backupRootNode || backupRootNode.kind !== "directory" ||
313
+ !backupManifestNode || backupManifestNode.kind !== "file" || backupManifestNode.bytesHash !== value.backupManifest ||
314
+ (startBackupParent ? !baselineParentIsPreserved : approvedBackupParent !== undefined)) {
315
+ throw new Error("Resolution record does not separate the root-relative owned backup inventory from the approved payload inventory.");
316
+ }
317
+ const ownerBytes = Buffer.from(originalOwner.rawBase64, "base64");
318
+ const ownerValue = parseOwner(JSON.parse(ownerBytes.toString("utf8")), value.token);
319
+ if (!ownerValue.migrationRootEntries)
320
+ throw new Error("Resolution record original owner lacks the v5 root-entry commitment.");
321
+ const claimValueRaw = JSON.parse(claimBytes.toString("utf8"));
322
+ const claimValue = parseClaimOwner(claimValueRaw, { root: value.root, token: value.token, resolutionId: value.resolutionId, journalRawHash: originalJournal.rawHash });
323
+ if (claimBytes.toString("utf8") !== canonicalJson(claimValue))
324
+ throw new Error("Typed resolution claim owner is not canonically encoded.");
325
+ const requiredLockNames = ["held-catalog", "old-catalog", "next-catalog", "journal.digest", "journal.json", "owner.json"];
326
+ const expectedLockHash = sha256(Buffer.from(canonicalJson(requiredLockNames.map((name) => [name, "file"]).sort(([a], [b]) => compareUtf8(String(a), String(b)))), "utf8"));
327
+ const expectedClaimHash = sha256(Buffer.from(canonicalJson([["owner.json", "file"]]), "utf8"));
328
+ const startCatalog = journal.startInventory.nodes.find((node) => node.path === "catalog.json");
329
+ const heldArtifact = cleanupArtifacts[0];
330
+ const oldArtifact = cleanupArtifacts[1];
331
+ const nextArtifact = cleanupArtifacts[2];
332
+ const digestArtifact = cleanupArtifacts[3];
333
+ const journalArtifact = cleanupArtifacts[4];
334
+ const ownerArtifact = cleanupArtifacts[5];
335
+ const claimOwnerArtifact = cleanupArtifacts[7];
336
+ if (!startCatalog || startCatalog.kind !== "file" || startCatalog.bytesHash !== value.oldCatalog ||
337
+ heldArtifact.hash !== value.oldCatalog || oldArtifact.hash !== value.oldCatalog ||
338
+ heldArtifact.dev !== startCatalog.dev || heldArtifact.ino !== startCatalog.ino || heldArtifact.mode !== startCatalog.mode ||
339
+ oldArtifact.dev !== startCatalog.dev || oldArtifact.ino !== startCatalog.ino || oldArtifact.mode !== startCatalog.mode ||
340
+ heldArtifact.dev !== oldArtifact.dev || heldArtifact.ino !== oldArtifact.ino ||
341
+ nextArtifact.hash !== value.newCatalog || nextArtifact.dev !== currentCatalog.dev || nextArtifact.ino !== currentCatalog.ino ||
342
+ nextArtifact.mode !== currentCatalog.mode ||
343
+ digestArtifact.hash !== sha256(Buffer.from(originalJournal.rawHash, "utf8")) || digestArtifact.mode !== 0o600 ||
344
+ journalArtifact.hash !== originalJournal.rawHash || journalArtifact.dev !== originalJournal.file.dev || journalArtifact.ino !== originalJournal.file.ino ||
345
+ journalArtifact.mode !== originalJournal.file.mode ||
346
+ ownerArtifact.hash !== originalOwner.rawHash || ownerArtifact.dev !== originalOwner.file.dev || ownerArtifact.ino !== originalOwner.file.ino ||
347
+ ownerArtifact.mode !== originalOwner.file.mode ||
348
+ claimOwnerArtifact.hash !== claim.owner.bytesHash || claimOwnerArtifact.dev !== claim.owner.dev || claimOwnerArtifact.ino !== claim.owner.ino ||
349
+ claimOwnerArtifact.mode !== claim.owner.mode)
350
+ throw new Error("Migration resolution artifact proofs are not bound to the v5 journal, catalog, owner, or claim.");
351
+ if (claimValue.lock.dev !== lock.dev || claimValue.lock.ino !== lock.ino || claimValue.lock.mode !== lock.mode ||
352
+ currentCatalog.kind !== "file" || currentCatalog.bytesHash !== value.newCatalog ||
353
+ !sameProof(lock, { kind: "directory", mode: cleanupArtifacts[6].mode, dev: cleanupArtifacts[6].dev, ino: cleanupArtifacts[6].ino }) ||
354
+ cleanupArtifacts[6].hash !== expectedLockHash || cleanupArtifacts[8].hash !== expectedClaimHash ||
355
+ !sameProof(claim.directory, { kind: "directory", mode: cleanupArtifacts[8].mode, dev: cleanupArtifacts[8].dev, ino: cleanupArtifacts[8].ino }) ||
356
+ !sameProof(originalJournal.file, { kind: "file", mode: cleanupArtifacts[4].mode, dev: cleanupArtifacts[4].dev, ino: cleanupArtifacts[4].ino, bytesHash: cleanupArtifacts[4].hash }) ||
357
+ !sameProof(originalOwner.file, { kind: "file", mode: cleanupArtifacts[5].mode, dev: cleanupArtifacts[5].dev, ino: cleanupArtifacts[5].ino, bytesHash: cleanupArtifacts[5].hash }) ||
358
+ !sameProof(claim.owner, { kind: "file", mode: cleanupArtifacts[7].mode, dev: cleanupArtifacts[7].dev, ino: cleanupArtifacts[7].ino, bytesHash: cleanupArtifacts[7].hash }) ||
359
+ canonicalJson(cleanupArtifacts) !== canonicalJson(value.cleanupArtifacts))
360
+ throw new Error("Resolution record proofs disagree with their journal, catalog, claim, or cleanup custody.");
361
+ return {
362
+ version: 1, mode: "legacy-migration-resolution", root: value.root, token: value.token, resolutionId: value.resolutionId,
363
+ generation: value.generation, predecessorHash: value.predecessorHash, originalJournal, oldCatalog: value.oldCatalog,
364
+ newCatalog: value.newCatalog, currentCatalog, backupDir: value.backupDir, backupManifest: value.backupManifest,
365
+ backupInventory, reviewId: value.reviewId, privateRevisionHash: value.privateRevisionHash, confirmed: true,
366
+ sensitivePaths: [...sensitivePaths], approvedInventory, originalOwner, lock, claim, cleanupArtifacts, progress,
367
+ };
368
+ }
369
+ function parseOwner(value, token) {
370
+ if (!isRecord(value) || utilTypes.isProxy(value))
371
+ throw new Error("Malformed migration transaction owner.");
372
+ const keys = Object.keys(value).sort(compareUtf8);
373
+ const allowed = ["blocked", "host", "migrationRootEntries", "pid", "token", "version"];
374
+ if (keys.some((key) => !allowed.includes(key)) || !["host", "pid", "token", "version"].every((key) => keys.includes(key)) ||
375
+ value.version !== 1 || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid < 1 || typeof value.host !== "string" ||
376
+ typeof value.token !== "string" || value.token !== token || (value.blocked !== undefined && typeof value.blocked !== "boolean")) {
377
+ throw new Error("Malformed migration transaction owner.");
378
+ }
379
+ if (value.migrationRootEntries !== undefined && (!Array.isArray(value.migrationRootEntries) || value.migrationRootEntries.some((entry) => typeof entry !== "string" ||
380
+ !entry || entry.includes("/") || entry.includes("\\") || entry.includes("\0") || entry === "." || entry === "..") ||
381
+ new Set(value.migrationRootEntries).size !== value.migrationRootEntries.length ||
382
+ value.migrationRootEntries.some((entry) => !isManagedMigrationRootName(entry) ||
383
+ [lockName, claimName, ".migration-backups"].includes(entry)) ||
384
+ canonicalJson([...value.migrationRootEntries].sort()) !== canonicalJson(value.migrationRootEntries))) {
385
+ throw new Error("Malformed migration root-entry commitment.");
386
+ }
387
+ return {
388
+ version: 1, pid: value.pid, host: value.host, token: value.token,
389
+ ...(value.blocked === undefined ? {} : { blocked: value.blocked }),
390
+ ...(value.migrationRootEntries === undefined ? {} : { migrationRootEntries: [...value.migrationRootEntries] }),
391
+ };
392
+ }
393
+ function alive(owner) {
394
+ if (owner.host !== os.hostname())
395
+ throw new Error("Migration lock owner is on an unknown host; preserving it.");
396
+ try {
397
+ process.kill(owner.pid, 0);
398
+ return true;
399
+ }
400
+ catch (error) {
401
+ if (error.code === "ESRCH")
402
+ return false;
403
+ throw new Error("Migration lock owner liveness is unknown; preserving all migration artifacts.", { cause: error });
404
+ }
405
+ }
406
+ function proofFromStat(stat, kind, bytesHash) {
407
+ const mode = Number(stat.mode & 4095n);
408
+ if (kind === "file")
409
+ return { kind, mode, dev: stat.dev.toString(), ino: stat.ino.toString(), bytesHash: bytesHash };
410
+ return { kind, mode, dev: stat.dev.toString(), ino: stat.ino.toString() };
411
+ }
412
+ function sameProof(left, right) {
413
+ return left.kind === right.kind && left.mode === right.mode && left.dev === right.dev && left.ino === right.ino &&
414
+ (left.kind !== "file" || right.kind === "file" && left.bytesHash === right.bytesHash);
415
+ }
416
+ async function statMaybe(target) {
417
+ return lstat(target, { bigint: true }).catch((error) => { if (error.code === "ENOENT")
418
+ return undefined; throw error; });
419
+ }
420
+ async function checkedFile(target) {
421
+ const before = await lstat(target, { bigint: true });
422
+ if (!before.isFile() || before.isSymbolicLink() || await realpath(target) !== target)
423
+ throw new Error(`Unsafe migration resolution file: ${target}`);
424
+ const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
425
+ try {
426
+ const opened = await handle.stat({ bigint: true });
427
+ const same = (left, right) => left.dev === right.dev && left.ino === right.ino && left.mode === right.mode &&
428
+ left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
429
+ if (!opened.isFile() || !same(before, opened))
430
+ throw new Error("Migration resolution file changed while opening.");
431
+ const bytes = await handle.readFile();
432
+ const [after, afterPath, canonical] = await Promise.all([handle.stat({ bigint: true }), lstat(target, { bigint: true }), realpath(target)]);
433
+ if (!after.isFile() || !afterPath.isFile() || afterPath.isSymbolicLink() || canonical !== target || !same(opened, after) || !same(opened, afterPath)) {
434
+ throw new Error("Migration resolution file changed while reading.");
435
+ }
436
+ return { bytes: Buffer.from(bytes), proof: proofFromStat(opened, "file", sha256(bytes)) };
437
+ }
438
+ finally {
439
+ await handle.close();
440
+ }
441
+ }
442
+ async function checkedDirectory(target, expectedMode) {
443
+ const before = await lstat(target, { bigint: true });
444
+ if (!before.isDirectory() || before.isSymbolicLink() || await realpath(target) !== target ||
445
+ (expectedMode !== undefined && Number(before.mode & 4095n) !== expectedMode))
446
+ throw new Error(`Unsafe migration resolution directory: ${target}`);
447
+ const after = await lstat(target, { bigint: true });
448
+ if (!after.isDirectory() || after.dev !== before.dev || after.ino !== before.ino || after.mode !== before.mode || await realpath(target) !== target) {
449
+ throw new Error("Migration resolution directory changed while checking its identity.");
450
+ }
451
+ return proofFromStat(after, "directory");
452
+ }
453
+ async function readCanonicalRoot(libraryDir) {
454
+ if (typeof libraryDir !== "string" || libraryDir.length === 0)
455
+ throw new Error("Library path must be a nonempty string.");
456
+ const root = path.resolve(libraryDir);
457
+ const stat = await statMaybe(root);
458
+ if (!stat)
459
+ return undefined;
460
+ if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(root) !== root)
461
+ throw new Error("Migration resolution requires a canonical real library directory.");
462
+ return root;
463
+ }
464
+ async function syncDirectory(target) {
465
+ const handle = await open(target, constants.O_RDONLY);
466
+ try {
467
+ await handle.sync();
468
+ }
469
+ finally {
470
+ await handle.close();
471
+ }
472
+ }
473
+ function inventoryHashEqual(left, right) {
474
+ return left.hash === right.hash && canonicalJson(left.nodes) === canonicalJson(right.nodes);
475
+ }
476
+ function inventoryNode(inventory, relative) {
477
+ return inventory.nodes.find((node) => node.path === relative);
478
+ }
479
+ function isExcluded(relative) {
480
+ return relative.split("/").some((part) => {
481
+ const lower = part.toLowerCase();
482
+ return excludedNames.has(lower) || lower === ".env" || lower.startsWith(".env");
483
+ });
484
+ }
485
+ function isUnder(target, root) { return target === root || target.startsWith(`${root}/`); }
486
+ function stableTreeHash(inventory, target) {
487
+ const rootNode = inventoryNode(inventory, target);
488
+ if (!rootNode)
489
+ return "absent";
490
+ if (rootNode.kind !== "directory")
491
+ throw new Error(`Migration ${target} root is not a directory.`);
492
+ const byPath = new Map(inventory.nodes.map((node) => [node.path, node]));
493
+ const rows = [];
494
+ const visit = (absoluteRelative, relative) => {
495
+ const node = byPath.get(absoluteRelative);
496
+ if (!node || node.kind !== "directory")
497
+ throw new Error(`Migration ${target} inventory lacks a directory.`);
498
+ rows.push({ path: relative, kind: "directory", value: "", mode: Number(BigInt(node.mode) & 511n), dev: Number(BigInt(node.dev)), ino: Number(BigInt(node.ino)) });
499
+ for (const child of inventory.nodes.filter((entry) => entry.path !== absoluteRelative && path.posix.dirname(entry.path) === absoluteRelative)
500
+ .sort((left, right) => path.posix.basename(left.path) < path.posix.basename(right.path) ? -1 : path.posix.basename(left.path) > path.posix.basename(right.path) ? 1 : 0)) {
501
+ const childRelative = relative === "." ? path.posix.basename(child.path) : `${relative}/${path.posix.basename(child.path)}`;
502
+ if (child.kind === "file")
503
+ rows.push({ path: childRelative, kind: "file", value: child.bytesHash, mode: Number(BigInt(child.mode) & 511n),
504
+ dev: Number(BigInt(child.dev)), ino: Number(BigInt(child.ino)) });
505
+ else
506
+ visit(child.path, childRelative);
507
+ }
508
+ };
509
+ visit(target, ".");
510
+ return sha256(Buffer.from(JSON.stringify(rows)));
511
+ }
512
+ async function sourceTreeFiles(root, item) {
513
+ const base = item.content.path;
514
+ const collected = [];
515
+ if (item.content.kind === "file") {
516
+ const file = await checkedFile(path.join(root, ...base.split("/")));
517
+ collected.push({ path: item.files[0]?.path ?? path.posix.basename(base), mode: file.proof.mode, bytes: file.bytes });
518
+ return collected;
519
+ }
520
+ const visit = async (absolute, relative) => {
521
+ const stat = await lstat(absolute, { bigint: true });
522
+ if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(absolute) !== absolute)
523
+ throw new Error("Adopted skill contains an unsafe directory.");
524
+ for (const rawName of await readdir(absolute, { encoding: "buffer" })) {
525
+ const name = new TextDecoder("utf-8", { fatal: true }).decode(rawName);
526
+ const childRelative = relative ? `${relative}/${name}` : name;
527
+ if (isExcluded(childRelative))
528
+ continue;
529
+ const child = path.join(absolute, name);
530
+ const childStat = await lstat(child, { bigint: true });
531
+ if (childStat.isSymbolicLink())
532
+ throw new Error("Adopted skill contains a symbolic link.");
533
+ if (childStat.isDirectory())
534
+ await visit(child, childRelative);
535
+ else if (childStat.isFile()) {
536
+ const file = await checkedFile(child);
537
+ collected.push({ path: childRelative, mode: file.proof.mode, bytes: file.bytes });
538
+ }
539
+ else
540
+ throw new Error("Adopted skill contains an unsupported special file.");
541
+ }
542
+ };
543
+ await visit(path.join(root, ...base.split("/")), "");
544
+ return collected.sort((left, right) => compareUtf8(left.path, right.path));
545
+ }
546
+ function text(bytes) {
547
+ if (bytes.length > 1_000_000 || bytes.includes(0))
548
+ return undefined;
549
+ try {
550
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
551
+ }
552
+ catch {
553
+ return undefined;
554
+ }
555
+ }
556
+ function resolutionDiff(itemId, name, before, after, identityChanged) {
557
+ if (before && after && before.equals(after) && identityChanged) {
558
+ return { path: `${itemId}/${name}`, summary: "Included file identity changed while its bytes remained identical; content is not displayed." };
559
+ }
560
+ const beforeText = before === undefined ? "" : text(before);
561
+ const afterText = after === undefined ? "" : text(after);
562
+ if (beforeText === undefined || afterText === undefined) {
563
+ const change = before === undefined ? "new" : after === undefined ? "removed" : "changed binary or large";
564
+ return { path: `${itemId}/${name}`, summary: `${change} included file; bytes are preserved and content is not displayed.` };
565
+ }
566
+ const prefix = before && after && !before.equals(after) ? "Included file bytes changed; masked diff follows:\n" : "";
567
+ return {
568
+ path: `${itemId}/${name}`,
569
+ summary: `${prefix}${maskSensitiveText(createTwoFilesPatch(before === undefined ? "(new)" : "verified backup", after === undefined ? "(removed)" : "current source", beforeText, afterText))}`,
570
+ };
571
+ }
572
+ function makeView(reviewId, status, values = {}) {
573
+ return {
574
+ reviewId, status, backupDir: values.backupDir ?? null, items: values.items ? values.items.map((item) => ({ ...item })) : [],
575
+ conflicts: values.conflicts ? [...values.conflicts] : [], preservedPaths: values.preservedPaths ? [...values.preservedPaths] : [],
576
+ maskedDiffs: values.maskedDiffs ? values.maskedDiffs.map((entry) => ({ ...entry })) : [],
577
+ sensitivePaths: values.sensitivePaths ? [...values.sensitivePaths] : [], warnings: values.warnings ? values.warnings.map((entry) => ({ ...entry })) : [],
578
+ };
579
+ }
580
+ function makePrepared(analysis) {
581
+ const plan = Object.freeze({ reviewId: analysis.reviewId });
582
+ preparedStates.set(plan, { root: analysis.root, view: analysis.view, revision: analysis.revision, analysis, used: false });
583
+ return plan;
584
+ }
585
+ function blocked(root, reason, reviewId = randomUUID()) {
586
+ return { root, status: "blocked", reviewId, view: makeView(reviewId, "blocked", { conflicts: [reason] }), revision: sha256(`${root}\0blocked\0${reason}`) };
587
+ }
588
+ function notNeeded(root, reviewId = randomUUID()) {
589
+ return { root, status: "not-needed", reviewId, view: makeView(reviewId, "not-needed"), revision: sha256(`${root}\0not-needed`) };
590
+ }
591
+ function filteredInventory(inventory, excludedPrefixes, omitted = []) {
592
+ return projectMigrationInventory(inventory, excludedPrefixes, omitted);
593
+ }
594
+ function backupPayloadExclusions(start, backupDir, extra = []) {
595
+ return [lockName, claimName, recordName, backupDir,
596
+ ...(!inventoryNode(start, ".migration-backups") ? [".migration-backups"] : []), ...extra];
597
+ }
598
+ function backupScopedInventory(inventory, backupDir) {
599
+ const backupName = path.posix.basename(backupDir);
600
+ const keep = (nodePath) => nodePath === "." || nodePath === backupName || nodePath.startsWith(`${backupName}/`);
601
+ const omitted = inventory.nodes.filter((node) => !keep(node.path)).map((node) => node.path);
602
+ const projected = filteredInventory(inventory, [], omitted);
603
+ const nodes = projected.nodes.map((node) => ({
604
+ ...node,
605
+ path: node.path === "." ? ".migration-backups" : `.migration-backups/${node.path}`,
606
+ }));
607
+ return {
608
+ version: 1,
609
+ nodes,
610
+ hash: sha256(Buffer.concat([
611
+ Buffer.from("copi-inventory-v1\0", "utf8"),
612
+ Buffer.from(canonicalJson(nodes), "utf8"),
613
+ ])),
614
+ };
615
+ }
616
+ function assertBackupParentListing(start, current, backupDir) {
617
+ const parentPath = ".migration-backups";
618
+ const baselineParent = inventoryNode(start, parentPath);
619
+ const currentParent = inventoryNode(current, parentPath);
620
+ if (!currentParent || currentParent.kind !== "directory" ||
621
+ baselineParent && (baselineParent.kind !== "directory" || baselineParent.dev !== currentParent.dev ||
622
+ baselineParent.ino !== currentParent.ino || baselineParent.mode !== currentParent.mode)) {
623
+ throw new Error("Migration backup parent identity or mode changed from the fixed start inventory.");
624
+ }
625
+ const children = (inventory) => inventory.nodes
626
+ .filter((node) => node.path !== parentPath && path.posix.dirname(node.path) === parentPath)
627
+ .map((node) => path.posix.basename(node.path)).sort(compareUtf8);
628
+ const baselineNames = children(start);
629
+ const ownedName = path.posix.basename(backupDir);
630
+ if (baselineNames.includes(ownedName))
631
+ throw new Error("Migration start inventory already contains the current backup UUID.");
632
+ const expectedNames = [...baselineNames, ownedName].sort(compareUtf8);
633
+ if (canonicalJson(children(current)) !== canonicalJson(expectedNames)) {
634
+ throw new Error("Migration backup parent listing differs from its start inventory plus the owned UUID.");
635
+ }
636
+ }
637
+ /**
638
+ * LIMIT: privateRevisionHash is durable commitment metadata, not authentication for a record read in a later process.
639
+ * The process-local prepared-plan revision is compared again during commit; manual edits to internal records remain outside this guarantee.
640
+ */
641
+ function reviewCommitment(record, observedRecord) {
642
+ const fixed = {
643
+ root: record.root, token: record.token, journalRawHash: record.originalJournal.rawHash,
644
+ oldCatalog: record.oldCatalog, newCatalog: record.newCatalog, currentCatalog: record.currentCatalog,
645
+ backupInventoryHash: record.backupInventory.hash, originalOwner: record.originalOwner,
646
+ lock: record.lock, payloadInventoryHash: record.approvedInventory.hash,
647
+ observedRecord, observedClaim: record.claim, cleanupArtifacts: record.cleanupArtifacts,
648
+ progress: record.progress, generation: record.generation, predecessorHash: record.predecessorHash,
649
+ reviewId: record.reviewId, sensitivePaths: record.sensitivePaths,
650
+ };
651
+ return sha256(Buffer.concat([Buffer.from("copi-resolution-review-v1\0", "utf8"), Buffer.from(canonicalJson(fixed), "utf8")]));
652
+ }
653
+ async function ownerIsDead(owner) {
654
+ if (alive(owner))
655
+ throw new Error("The original migration owner is active; preserving its journal and backup.");
656
+ }
657
+ function currentRecordTemp(root, record) {
658
+ return path.join(root, `${resolutionTempPrefix}${record.resolutionId}.${record.generation}.tmp`);
659
+ }
660
+ function isRecordTempName(name) { return name !== recordName && name.startsWith(resolutionTempPrefix); }
661
+ async function readRecord(root) {
662
+ const target = path.join(root, recordName);
663
+ const stat = await statMaybe(target);
664
+ if (!stat)
665
+ return undefined;
666
+ const loaded = await checkedFile(target);
667
+ if (loaded.proof.mode !== 0o600)
668
+ throw new Error("Migration resolution record must be a regular mode-0600 file.");
669
+ let value;
670
+ try {
671
+ value = JSON.parse(loaded.bytes.toString("utf8"));
672
+ }
673
+ catch {
674
+ throw new Error("Migration resolution record is malformed; preserving it.");
675
+ }
676
+ const record = parseRecord(value);
677
+ if (record.root !== root || loaded.bytes.toString("utf8") !== canonicalJson(record))
678
+ throw new Error("Migration resolution record root or canonical bytes changed.");
679
+ return { record, raw: loaded.bytes, proof: loaded.proof };
680
+ }
681
+ async function checkTemps(root, record) {
682
+ const names = await listManagedMigrationRootEntries(root);
683
+ const temps = names.filter(isRecordTempName);
684
+ if (temps.length === 0)
685
+ return undefined;
686
+ if (!record || temps.length !== 1 || temps[0] !== path.basename(currentRecordTemp(root, record.record))) {
687
+ throw new Error("Unknown or incomplete migration resolution temporary file; preserving it.");
688
+ }
689
+ const temp = path.join(root, temps[0]);
690
+ const loaded = await checkedFile(temp);
691
+ if (!sameProof(loaded.proof, record.proof) || !loaded.bytes.equals(record.raw))
692
+ throw new Error("Migration resolution temporary is not an owned completed-record alias.");
693
+ return temp;
694
+ }
695
+ function exactInventoryMap(inventory) {
696
+ return new Map(inventory.nodes.map((node) => [node.path, node]));
697
+ }
698
+ function equalNodeWithoutContents(left, right) {
699
+ return left.path === right.path && left.kind === right.kind && left.mode === right.mode && left.dev === right.dev && left.ino === right.ino;
700
+ }
701
+ function verifyAllowedPayloadChanges(root, start, current, oldCatalog, nextCatalog, excludedPrefixes) {
702
+ const baseline = exactInventoryMap(start);
703
+ const now = exactInventoryMap(current);
704
+ const nextItems = new Map(nextCatalog.items.map((item) => [item.id, item]));
705
+ const allowedRoot = new Map();
706
+ for (const oldItem of oldCatalog.items) {
707
+ const next = nextItems.get(oldItem.id);
708
+ const expectedSource = path.relative(root, oldItem.sourcePath).split(path.sep).join("/");
709
+ if (!next || !expectedSource.startsWith("sources/") || next.content.path !== expectedSource || isExcluded(expectedSource)) {
710
+ throw new Error("Migration catalog does not retain the exact adopted sources path.");
711
+ }
712
+ if (oldItem.kind === "skill") {
713
+ if (next.content.kind !== "tree")
714
+ throw new Error("Adopted skill target changed kind.");
715
+ allowedRoot.set(expectedSource, { item: oldItem, kind: "skill" });
716
+ }
717
+ else {
718
+ if (next.content.kind !== "file")
719
+ throw new Error("Adopted file target changed kind.");
720
+ allowedRoot.set(expectedSource, { item: oldItem, kind: "file" });
721
+ }
722
+ }
723
+ const authorized = new Set(["catalog.json"]);
724
+ const changedPaths = [];
725
+ const preserved = [];
726
+ for (const [sourcePath, rule] of allowedRoot) {
727
+ const registered = new Set(rule.item.files.map((file) => rule.kind === "skill" ? `${sourcePath}/${file.path}` : sourcePath));
728
+ const descendants = new Set();
729
+ for (const node of start.nodes) {
730
+ if (node.kind === "file" && (rule.kind === "skill" ? isUnder(node.path, sourcePath) && node.path !== sourcePath : node.path === sourcePath))
731
+ descendants.add(node.path);
732
+ }
733
+ for (const node of current.nodes) {
734
+ if (node.kind === "file" && (rule.kind === "skill" ? isUnder(node.path, sourcePath) && node.path !== sourcePath : node.path === sourcePath))
735
+ descendants.add(node.path);
736
+ }
737
+ for (const filePath of descendants) {
738
+ const old = baseline.get(filePath);
739
+ const actual = now.get(filePath);
740
+ const relative = rule.kind === "skill" ? filePath.slice(`${sourcePath}/`.length) : path.posix.basename(sourcePath);
741
+ if (isExcluded(relative))
742
+ continue;
743
+ if (old && !registered.has(filePath))
744
+ continue; // Unregistered baseline nodes remain protected by the full inventory comparison.
745
+ if (!old && actual) {
746
+ if (rule.kind !== "skill" || actual.kind !== "file")
747
+ throw new Error("Only regular files may be added inside an adopted skill.");
748
+ const parent = path.posix.dirname(filePath);
749
+ if (!baseline.has(parent) || baseline.get(parent).kind !== "directory")
750
+ throw new Error("New skill files require an unchanged baseline parent directory.");
751
+ authorized.add(filePath);
752
+ changedPaths.push(filePath);
753
+ }
754
+ else if (old && !actual) {
755
+ if (rule.kind !== "skill" || relative === "SKILL.md" || old.kind !== "file" || !registered.has(filePath)) {
756
+ throw new Error("Only previously included non-SKILL.md skill files may be deleted.");
757
+ }
758
+ authorized.add(filePath);
759
+ changedPaths.push(filePath);
760
+ }
761
+ else if (old && actual && registered.has(filePath)) {
762
+ if (old.kind !== "file" || actual.kind !== "file" || old.mode !== actual.mode)
763
+ throw new Error("An existing included source file changed kind or mode.");
764
+ authorized.add(filePath);
765
+ if (old.bytesHash !== actual.bytesHash || old.dev !== actual.dev || old.ino !== actual.ino)
766
+ changedPaths.push(filePath);
767
+ }
768
+ }
769
+ }
770
+ const isOmitted = (nodePath) => excludedPrefixes.some((prefix) => nodePath === prefix || nodePath.startsWith(`${prefix}/`));
771
+ // Directories are immutable; only their child-list commitments may follow approved file additions/deletions.
772
+ for (const node of start.nodes) {
773
+ if (node.kind !== "directory" || node.path === "." || isOmitted(node.path))
774
+ continue;
775
+ const actual = now.get(node.path);
776
+ if (!actual || actual.kind !== "directory" || !equalNodeWithoutContents(node, actual))
777
+ throw new Error(`A protected library directory changed: ${node.path}`);
778
+ }
779
+ for (const node of current.nodes) {
780
+ if (node.kind === "directory" && !baseline.has(node.path) && !isOmitted(node.path))
781
+ throw new Error(`A new library directory is outside the adopt-only allowlist: ${node.path}`);
782
+ }
783
+ const currentProjected = filteredInventory(current, excludedPrefixes, [...authorized]);
784
+ const baselineProjected = filteredInventory(start, excludedPrefixes, [...authorized]);
785
+ if (!inventoryHashEqual(currentProjected, baselineProjected)) {
786
+ const names = new Set([...baseline.keys(), ...now.keys()]);
787
+ for (const name of names) {
788
+ if (authorized.has(name) || isOmitted(name))
789
+ continue;
790
+ const old = baseline.get(name), actual = now.get(name);
791
+ if (!old || !actual || canonicalJson(old) !== canonicalJson(actual))
792
+ preserved.push(name);
793
+ }
794
+ throw new Error(`A protected library node changed outside the adopt-only source allowlist${preserved.length ? `: ${preserved.sort(compareUtf8).join(", ")}` : ""}.`);
795
+ }
796
+ return { changedPaths: [...new Set(changedPaths)].sort(compareUtf8), omitted: [...authorized], preserved: preserved.sort(compareUtf8) };
797
+ }
798
+ function assertCatalogStructure(value, version) {
799
+ if (!exactKeys(value, ["version", "items", "groups"]) || value.version !== version || !Array.isArray(value.items) || !Array.isArray(value.groups)) {
800
+ throw new Error("Migration catalog contains unknown or malformed top-level fields.");
801
+ }
802
+ const itemKeys = version === 1
803
+ ? ["id", "kind", "source", "scope", "sourcePath", "destination", "name", "savedAt", "files"]
804
+ : ["id", "kind", "source", "scope", "sourcePath", "destination", "name", "savedAt", "files", "content"];
805
+ for (const item of value.items) {
806
+ if (!exactKeys(item, itemKeys))
807
+ throw new Error("Migration catalog item contains unknown or malformed fields.");
808
+ const destination = item.destination;
809
+ if (!exactKeys(destination, isRecord(destination) && destination.kind === "project" ? ["kind", "path"] : ["kind"])) {
810
+ throw new Error("Migration catalog destination contains unknown or malformed fields.");
811
+ }
812
+ if (!Array.isArray(item.files))
813
+ throw new Error("Migration catalog file descriptors are malformed.");
814
+ for (const file of item.files)
815
+ if (!exactKeys(file, ["path", "mode", "sha256"]))
816
+ throw new Error("Migration catalog file descriptor contains unknown fields.");
817
+ if (version === 2 && !exactKeys(item.content, ["kind", "path"]))
818
+ throw new Error("Migration catalog content location contains unknown fields.");
819
+ }
820
+ for (const group of value.groups)
821
+ if (!exactKeys(group, ["id", "name", "itemIds"]))
822
+ throw new Error("Migration catalog group contains unknown fields.");
823
+ }
824
+ function originalCatalogFromBackup(root, journal) {
825
+ return (async () => {
826
+ const target = path.join(root, ...journal.backupDir.split("/"), "files", "catalog.json");
827
+ const { bytes } = await checkedFile(target);
828
+ if (sha256(bytes) !== journal.oldCatalog)
829
+ throw new Error("Verified migration backup lacks its original v1 catalog bytes.");
830
+ const raw = JSON.parse(bytes.toString("utf8"));
831
+ assertCatalogStructure(raw, 1);
832
+ const parsed = parseStoredCatalog(raw);
833
+ if (parsed.version !== 1)
834
+ throw new Error("Migration backup catalog is not v1.");
835
+ return parsed;
836
+ })();
837
+ }
838
+ async function validateBackup(root, journal, start, currentInventory) {
839
+ assertBackupParentListing(start, currentInventory, journal.backupDir);
840
+ const manifestPath = path.join(root, ...journal.backupDir.split("/"), "manifest.json");
841
+ const loaded = await checkedFile(manifestPath);
842
+ if (sha256(loaded.bytes) !== journal.backupManifest)
843
+ throw new Error("Verified migration backup manifest hash changed.");
844
+ const manifest = parseMigrationBackupManifest(loaded.bytes);
845
+ const expectedPaths = start.nodes.filter((node) => node.kind === "file" &&
846
+ (node.path === "catalog.json" || node.path.startsWith("items/") || node.path.startsWith("sources/")))
847
+ .map((node) => node.path).sort();
848
+ const actualPaths = manifest.entries.map((entry) => entry.originalPath);
849
+ if (canonicalJson(expectedPaths) !== canonicalJson(actualPaths))
850
+ throw new Error("Migration backup does not cover the exact migration-start catalog/items/sources files.");
851
+ for (const entry of manifest.entries) {
852
+ const node = inventoryNode(start, entry.originalPath);
853
+ if (!node || node.kind !== "file" || node.bytesHash !== entry.sha256 || (node.mode & 0o777) !== entry.mode) {
854
+ throw new Error("Migration backup manifest is not bound to the original start inventory.");
855
+ }
856
+ }
857
+ await verifyMigrationBackup({ libraryRoot: root, backupDir: journal.backupDir, manifestBytes: loaded.bytes,
858
+ manifestSha256: journal.backupManifest, entries: manifest.entries.map(({ originalPath, sha256: digest, mode }) => ({ originalPath, sha256: digest, mode })) });
859
+ const namespace = await scanMigrationInventory(path.join(root, ".migration-backups"), { rootScope: "subtree" });
860
+ const scannedParent = inventoryNode(namespace, ".");
861
+ const currentParent = inventoryNode(currentInventory, ".migration-backups");
862
+ if (!scannedParent || scannedParent.kind !== "directory" || !currentParent || currentParent.kind !== "directory" ||
863
+ scannedParent.dev !== currentParent.dev || scannedParent.ino !== currentParent.ino || scannedParent.mode !== currentParent.mode) {
864
+ throw new Error("Migration backup parent identity changed while custody was being checked.");
865
+ }
866
+ return backupScopedInventory(namespace, journal.backupDir);
867
+ }
868
+ async function verifyStableTrees(inventory, journal) {
869
+ if (stableTreeHash(inventory, "items") !== journal.stableItemsTree || stableTreeHash(inventory, "sources") !== journal.stableSourcesTree ||
870
+ journal.stableSourcesTreeNext !== journal.stableSourcesTree)
871
+ throw new Error("Migration-start items or sources tree commitment differs from the v5 journal.");
872
+ }
873
+ async function validateCatalogs(root, journal, oldCatalog) {
874
+ const catalogPath = path.join(root, "catalog.json");
875
+ const current = await checkedFile(catalogPath);
876
+ if (current.proof.bytesHash !== journal.newCatalog)
877
+ throw new Error("Resolution requires the catalog-switched v2 state.");
878
+ const rawCurrent = JSON.parse(current.bytes.toString("utf8"));
879
+ assertCatalogStructure(rawCurrent, 2);
880
+ const currentValue = parseStoredCatalog(rawCurrent);
881
+ if (currentValue.version !== 2 || !current.bytes.equals(Buffer.from(`${JSON.stringify(currentValue, null, 2)}\n`, "utf8"))) {
882
+ throw new Error("Resolution requires the exact committed canonical v2 catalog bytes.");
883
+ }
884
+ const nextPath = path.join(root, lockName, "next-catalog");
885
+ const next = await checkedFile(nextPath);
886
+ if (next.proof.bytesHash !== journal.newCatalog || !next.bytes.equals(current.bytes) || next.proof.dev !== current.proof.dev || next.proof.ino !== current.proof.ino) {
887
+ throw new Error("Current v2 catalog is not the exact installed next-catalog inode.");
888
+ }
889
+ validateLegacyMigrationJournalTargets(journal, oldCatalog, currentValue);
890
+ const oldNode = inventoryNode(journal.startInventory, "catalog.json");
891
+ const oldStage = await checkedFile(path.join(root, lockName, "old-catalog"));
892
+ const held = await checkedFile(path.join(root, lockName, "held-catalog"));
893
+ if (!oldNode || oldNode.kind !== "file" || oldStage.proof.dev !== oldNode.dev || oldStage.proof.ino !== oldNode.ino ||
894
+ held.proof.dev !== oldNode.dev || held.proof.ino !== oldNode.ino || oldStage.proof.bytesHash !== journal.oldCatalog ||
895
+ held.proof.bytesHash !== journal.oldCatalog || oldStage.proof.mode !== oldNode.mode || held.proof.mode !== oldNode.mode) {
896
+ throw new Error("Original catalog held/old inode does not match the migration-start inventory and backup.");
897
+ }
898
+ return { next: currentValue, proof: current.proof };
899
+ }
900
+ function initialArtifacts(lock, proofs) {
901
+ const fileNames = [
902
+ ".copi-transaction/held-catalog", ".copi-transaction/old-catalog", ".copi-transaction/next-catalog",
903
+ ".copi-transaction/journal.digest", ".copi-transaction/journal.json", ".copi-transaction/owner.json",
904
+ ];
905
+ if (proofs.length !== fileNames.length)
906
+ throw new Error("Resolution cleanup requires the exact six original transaction files.");
907
+ const artifacts = proofs.map((proof, index) => ({ name: fileNames[index], kind: "file", mode: proof.mode, dev: proof.dev, ino: proof.ino, hash: proof.bytesHash }));
908
+ artifacts.push({ name: lockName, kind: "directory", mode: lock.mode, dev: lock.dev, ino: lock.ino,
909
+ hash: sha256(Buffer.from(canonicalJson(fileNames.map((name) => [path.posix.basename(name), "file"]).sort(([a], [b]) => compareUtf8(String(a), String(b)))), "utf8")) });
910
+ return artifacts;
911
+ }
912
+ function claimArtifacts(directory, owner) {
913
+ return [
914
+ { name: `${claimName}/owner.json`, kind: "file", mode: owner.mode, dev: owner.dev, ino: owner.ino, hash: owner.bytesHash },
915
+ { name: claimName, kind: "directory", mode: directory.mode, dev: directory.dev, ino: directory.ino,
916
+ hash: sha256(Buffer.from(canonicalJson([["owner.json", "file"]]), "utf8")) },
917
+ ];
918
+ }
919
+ function compareCatalogIdentity(expected, actual) {
920
+ return expected.kind === "file" && actual.kind === "file" && expected.bytesHash === actual.bytesHash && expected.mode === actual.mode &&
921
+ expected.dev === actual.dev && expected.ino === actual.ino;
922
+ }
923
+ function secretAndDiffView(root, oldCatalog, nextCatalog, journal, identityChangedPaths) {
924
+ return (async () => {
925
+ const items = [];
926
+ const maskedDiffs = [];
927
+ const sensitive = new Set();
928
+ const warnings = [];
929
+ const backupRoot = path.join(root, ...journal.backupDir.split("/"), "files");
930
+ for (const oldItem of oldCatalog.items) {
931
+ const next = nextCatalog.items.find((item) => item.id === oldItem.id);
932
+ if (!next || !next.content.path.startsWith("sources/"))
933
+ throw new Error("Resolution catalog item lost its adopted source target.");
934
+ items.push({ id: next.id, name: next.name, target: next.content.path, selection: "sources" });
935
+ const beforeFiles = await Promise.all(oldItem.files.map(async (file) => {
936
+ const originalPath = oldItem.kind === "skill" ? `${next.content.path}/${file.path}` : next.content.path;
937
+ const loaded = await checkedFile(path.join(backupRoot, ...originalPath.split("/")));
938
+ return { path: file.path, mode: file.mode, bytes: loaded.bytes };
939
+ }));
940
+ const afterFiles = await sourceTreeFiles(root, next);
941
+ const beforeInspect = inspectFixedResource({ candidate: { ...oldItem, sourceRoot: path.dirname(oldItem.sourcePath) }, files: beforeFiles, excludedPaths: [] });
942
+ const afterInspect = inspectFixedResource({ candidate: { ...next, sourceRoot: path.dirname(next.sourcePath) }, files: afterFiles, excludedPaths: [] });
943
+ for (const entry of beforeInspect.sensitivePaths)
944
+ sensitive.add(`${oldItem.id}/${entry}`);
945
+ for (const entry of afterInspect.sensitivePaths)
946
+ sensitive.add(`${oldItem.id}/${entry}`);
947
+ for (const warning of [...beforeInspect.warnings, ...afterInspect.warnings])
948
+ warnings.push({ code: warning.code, message: warning.message,
949
+ ...(warning.path === undefined ? {} : { path: `${oldItem.id}/${warning.path}` }) });
950
+ const before = new Map(beforeFiles.map((file) => [file.path, file.bytes]));
951
+ const after = new Map(afterFiles.map((file) => [file.path, file.bytes]));
952
+ for (const relative of [...new Set([...before.keys(), ...after.keys()])].sort(compareUtf8)) {
953
+ const oldBytes = before.get(relative), newBytes = after.get(relative);
954
+ const absolutePath = next.content.kind === "file" ? next.content.path : `${next.content.path}/${relative}`;
955
+ const identityChanged = identityChangedPaths.includes(absolutePath);
956
+ if (oldBytes && newBytes && oldBytes.equals(newBytes) && !identityChanged)
957
+ continue;
958
+ maskedDiffs.push(resolutionDiff(oldItem.id, relative, oldBytes, newBytes, identityChanged));
959
+ }
960
+ }
961
+ const sensitivePaths = [...sensitive].sort(compareUtf8);
962
+ return { items, maskedDiffs, sensitivePaths, warnings };
963
+ })();
964
+ }
965
+ async function readInitialContext(root, reviewId) {
966
+ const recordState = await readRecord(root);
967
+ const tempPath = await checkTemps(root, recordState);
968
+ const lockPath = path.join(root, lockName);
969
+ const lockStat = await statMaybe(lockPath);
970
+ if (!lockStat) {
971
+ if (recordState)
972
+ return readRecordedContext(root, reviewId, recordState, tempPath);
973
+ if (await statMaybe(path.join(root, claimName)))
974
+ throw new Error("A recovery claim without its typed resolution record is preserved and blocked.");
975
+ if (tempPath)
976
+ throw new Error("A completed temporary resolution record has no authoritative root record.");
977
+ return notNeeded(root, reviewId);
978
+ }
979
+ if (recordState)
980
+ return readRecordedContext(root, reviewId, recordState, tempPath);
981
+ if (await statMaybe(path.join(root, claimName)))
982
+ throw new Error("An ownerless or unrecorded recovery claim is preserved and blocked.");
983
+ const lock = await checkedDirectory(lockPath, 0o700);
984
+ const names = (await readdir(lockPath)).sort(compareUtf8);
985
+ const required = ["held-catalog", "journal.digest", "journal.json", "next-catalog", "old-catalog", "owner.json"];
986
+ if (canonicalJson(names) !== canonicalJson(required))
987
+ throw new Error("Migration lock contains unknown, missing, or incomplete artifacts; preserving it.");
988
+ const journalFile = await checkedFile(path.join(lockPath, "journal.json"));
989
+ const digestFile = await checkedFile(path.join(lockPath, "journal.digest"));
990
+ if (journalFile.proof.mode !== 0o600 || digestFile.proof.mode !== 0o600 || digestFile.bytes.toString("utf8") !== sha256(journalFile.bytes)) {
991
+ throw new Error("Migration journal or digest is not the exact durable canonical record.");
992
+ }
993
+ const journalValue = JSON.parse(journalFile.bytes.toString("utf8"));
994
+ if (!isRecord(journalValue) || typeof journalValue.token !== "string")
995
+ throw new Error("Migration journal has no exact token field.");
996
+ const journal = parseStorageJournal(journalValue, { root, token: journalValue.token });
997
+ if (journal.version !== 5)
998
+ throw new Error("This resolution procedure requires a v5 start inventory; v4 remains in ordinary hold-stop.");
999
+ if (journal.version !== 5 || journalFile.bytes.toString("utf8") !== canonicalStorageJournalJson(journal))
1000
+ throw new Error("Migration v5 journal is not canonical or does not match its root/token.");
1001
+ const ownerFile = await checkedFile(path.join(lockPath, "owner.json"));
1002
+ if (ownerFile.proof.mode !== 0o600)
1003
+ throw new Error("Migration transaction owner must be mode 0600.");
1004
+ const owner = parseOwner(JSON.parse(ownerFile.bytes.toString("utf8")), journal.token);
1005
+ if (!owner.migrationRootEntries)
1006
+ throw new Error("Migration transaction owner lacks its fixed root-entry commitment.");
1007
+ await ownerIsDead(owner);
1008
+ const start = parseMigrationStartInventory(journal.startInventory);
1009
+ await verifyStableTrees(start, journal);
1010
+ const currentInventory = await scanMigrationInventory(root, { excludedPrefixes: [lockName, claimName, recordName] });
1011
+ const backupInventory = await validateBackup(root, journal, start, currentInventory);
1012
+ const oldCatalog = await originalCatalogFromBackup(root, journal);
1013
+ const { next, proof: currentCatalogProof } = await validateCatalogs(root, journal, oldCatalog);
1014
+ const baselineLock = inventoryNode(start, lockName);
1015
+ const baselineClaim = inventoryNode(start, claimName);
1016
+ if (baselineLock || baselineClaim || inventoryNode(start, recordName))
1017
+ throw new Error("Migration start inventory already contained a resolution or transaction control artifact.");
1018
+ const backupParent = inventoryNode(start, ".migration-backups");
1019
+ const hasBackupParent = !!backupParent;
1020
+ const excluded = backupPayloadExclusions(start, journal.backupDir);
1021
+ const classification = verifyAllowedPayloadChanges(root, start, currentInventory, oldCatalog, next, excluded);
1022
+ const projected = filteredInventory(currentInventory, excluded);
1023
+ const rootNames = await listManagedMigrationRootEntries(root);
1024
+ const currentEntries = rootNames.filter((name) => name !== lockName && name !== claimName &&
1025
+ (hasBackupParent || name !== ".migration-backups"));
1026
+ const expectedEntries = [...owner.migrationRootEntries].sort();
1027
+ if (canonicalJson(currentEntries) !== canonicalJson(expectedEntries))
1028
+ throw new Error("Root entries differ from the fixed v5 owner commitment outside the owned transaction.");
1029
+ const artifactsBeforeClaim = [
1030
+ (await checkedFile(path.join(lockPath, "held-catalog"))).proof,
1031
+ (await checkedFile(path.join(lockPath, "old-catalog"))).proof,
1032
+ (await checkedFile(path.join(lockPath, "next-catalog"))).proof,
1033
+ digestFile.proof, journalFile.proof, ownerFile.proof,
1034
+ ];
1035
+ const initial = initialArtifacts(lock, artifactsBeforeClaim);
1036
+ const changes = await secretAndDiffView(root, oldCatalog, next, journal, classification.changedPaths);
1037
+ const inventoryAfterView = await scanMigrationInventory(root, { excludedPrefixes: [lockName, claimName, recordName] });
1038
+ if (!inventoryHashEqual(currentInventory, inventoryAfterView))
1039
+ throw new Error("Migration resolution inputs changed while masked review data was assembled.");
1040
+ const preservedPaths = classification.preserved;
1041
+ const view = makeView(reviewId, "ready", { backupDir: journal.backupDir, items: changes.items, maskedDiffs: changes.maskedDiffs,
1042
+ sensitivePaths: changes.sensitivePaths, warnings: changes.warnings, preservedPaths });
1043
+ const revision = sha256(canonicalJson({ journal: sha256(journalFile.bytes), owner: sha256(ownerFile.bytes), lock,
1044
+ currentCatalogProof, start: start.hash, current: projected.hash, backup: backupInventory.hash, changes: classification.changedPaths,
1045
+ secrets: changes.sensitivePaths, artifacts: initial.slice(0, 6), record: null, claim: null }));
1046
+ return { root, status: "ready", reviewId, view, revision, journal, journalRaw: journalFile.bytes, owner, ownerRaw: ownerFile.bytes,
1047
+ ownerProof: ownerFile.proof, originalCatalog: oldCatalog, nextCatalog: next, currentCatalogProof, lockProof: lock,
1048
+ backupInventory, approvedInventory: filteredInventory(currentInventory, excluded), cleanupArtifacts: initial.slice(0, 6), preservedPaths };
1049
+ }
1050
+ async function readRecordedContext(root, reviewId, recordState, tempPath) {
1051
+ const record = recordState.record;
1052
+ if (record.root !== root)
1053
+ throw new Error("Resolution record is bound to another library root.");
1054
+ const journal = parseStorageJournal(JSON.parse(Buffer.from(record.originalJournal.rawBase64, "base64").toString("utf8")), { root, token: record.token });
1055
+ if (journal.version !== 5 || journal.startInventory.hash !== (parseMigrationStartInventory(journal.startInventory)).hash)
1056
+ throw new Error("Resolution record has an invalid original v5 baseline.");
1057
+ const originalOwner = parseOwner(JSON.parse(Buffer.from(record.originalOwner.rawBase64, "base64").toString("utf8")), record.token);
1058
+ if (alive(originalOwner))
1059
+ throw new Error("Original migration owner is active; preserving the resolution record.");
1060
+ const claimDirPath = path.join(root, claimName);
1061
+ const claimStat = await statMaybe(claimDirPath);
1062
+ let claimOwner;
1063
+ let claimOwnerRaw;
1064
+ let claimDirectoryProof;
1065
+ let claimOwnerProof;
1066
+ if (claimStat) {
1067
+ claimDirectoryProof = await checkedDirectory(claimDirPath, 0o700);
1068
+ if (!sameProof(claimDirectoryProof, record.claim.directory))
1069
+ throw new Error("Typed migration claim directory differs from the record proof.");
1070
+ const ownerPath = path.join(claimDirPath, "owner.json");
1071
+ if (await statMaybe(ownerPath)) {
1072
+ const ownerFile = await checkedFile(ownerPath);
1073
+ if (ownerFile.proof.mode !== 0o600)
1074
+ throw new Error("Typed resolution claim owner must be mode 0600.");
1075
+ claimOwnerRaw = ownerFile.bytes;
1076
+ claimOwnerProof = ownerFile.proof;
1077
+ claimOwner = parseClaimOwner(JSON.parse(ownerFile.bytes.toString("utf8")), { root, token: record.token, resolutionId: record.resolutionId, journalRawHash: record.originalJournal.rawHash });
1078
+ if (!sameProof(claimOwnerProof, record.claim.owner) || ownerFile.bytes.toString("base64") !== record.claim.ownerRawBase64 ||
1079
+ ownerFile.bytes.toString("utf8") !== canonicalJson(claimOwner))
1080
+ throw new Error("Typed migration claim differs from the record's creator proof.");
1081
+ if (claimOwner.host !== os.hostname() || isProcessAlive(claimOwner.pid))
1082
+ throw new Error("Typed resolution claim creator is active or unknown; preserving the claim.");
1083
+ }
1084
+ }
1085
+ else if (record.progress.completed < 8 && !(record.progress.pending === 8)) {
1086
+ throw new Error("Typed resolution claim disappeared before its write-ahead cleanup prefix allowed it.");
1087
+ }
1088
+ const temp = await checkTemps(root, recordState);
1089
+ if (tempPath && temp !== tempPath)
1090
+ throw new Error("Resolution temp changed after review.");
1091
+ const start = parseMigrationStartInventory(journal.startInventory);
1092
+ const currentInventory = await scanMigrationInventory(root, { excludedPrefixes: [lockName, claimName, recordName, ...(temp ? [path.basename(temp)] : [])] });
1093
+ const exclusions = backupPayloadExclusions(start, record.backupDir, temp ? [path.basename(temp)] : []);
1094
+ const oldCatalog = await originalCatalogFromBackup(root, journal);
1095
+ const nextCatalogValue = await parseCurrentCatalog(root, record.currentCatalog);
1096
+ validateLegacyMigrationJournalTargets(journal, oldCatalog, nextCatalogValue);
1097
+ await verifyStableTreesFromBaseline(journal, start);
1098
+ const backupInventory = await validateBackup(root, journal, start, currentInventory);
1099
+ if (!inventoryHashEqual(backupInventory, record.backupInventory))
1100
+ throw new Error("Verified migration backup inventory differs from the durable resolution record.");
1101
+ const currentCatalog = await checkedFile(path.join(root, "catalog.json"));
1102
+ if (!compareCatalogIdentity(record.currentCatalog, currentCatalog.proof))
1103
+ throw new Error("Current catalog bytes, mode, or inode changed after resolution review.");
1104
+ const classification = verifyAllowedPayloadChanges(root, start, currentInventory, oldCatalog, nextCatalogValue, exclusions);
1105
+ const currentApproved = filteredInventory(currentInventory, exclusions);
1106
+ const needsGeneration = !inventoryHashEqual(currentApproved, record.approvedInventory);
1107
+ const progressIssue = await checkProgressArtifacts(root, record, recordState);
1108
+ if (progressIssue)
1109
+ throw new Error(progressIssue);
1110
+ const changes = await secretAndDiffView(root, oldCatalog, nextCatalogValue, journal, classification.changedPaths);
1111
+ const inventoryAfterView = await scanMigrationInventory(root, { excludedPrefixes: [lockName, claimName, recordName, ...(temp ? [path.basename(temp)] : [])] });
1112
+ if (!inventoryHashEqual(currentInventory, inventoryAfterView))
1113
+ throw new Error("Migration resolution inputs changed while masked review data was assembled.");
1114
+ const recordAfterView = await readRecord(root);
1115
+ if (!recordAfterView || !recordAfterView.raw.equals(recordState.raw) || !sameProof(recordAfterView.proof, recordState.proof)) {
1116
+ throw new Error("Migration resolution record changed while masked review data was assembled.");
1117
+ }
1118
+ const progressAfterView = await checkProgressArtifacts(root, record, recordState);
1119
+ if (progressAfterView)
1120
+ throw new Error(progressAfterView);
1121
+ const view = makeView(reviewId, "ready", { backupDir: journal.backupDir, items: changes.items, maskedDiffs: changes.maskedDiffs,
1122
+ sensitivePaths: changes.sensitivePaths, warnings: changes.warnings, preservedPaths: classification.preserved });
1123
+ const revision = sha256(canonicalJson({ recordHash: sha256(recordState.raw), recordIdentity: recordState.proof,
1124
+ currentApproved: currentApproved.hash, backup: backupInventory.hash, catalog: currentCatalog.proof,
1125
+ claim: claimOwnerRaw ? sha256(claimOwnerRaw) : null, progress: record.progress, changed: classification.changedPaths,
1126
+ sensitivePaths: changes.sensitivePaths, generation: record.generation, predecessorHash: record.predecessorHash }));
1127
+ return { root, status: "ready", reviewId, view, revision, journal, journalRaw: Buffer.from(record.originalJournal.rawBase64, "base64"),
1128
+ owner: originalOwner, ownerRaw: Buffer.from(record.originalOwner.rawBase64, "base64"), ownerProof: record.originalOwner.file,
1129
+ originalCatalog: oldCatalog, nextCatalog: nextCatalogValue, currentCatalogProof: currentCatalog.proof,
1130
+ lockProof: record.lock, ...(claimOwner ? { claimOwner } : {}), ...(claimOwnerRaw ? { claimOwnerRaw } : {}),
1131
+ ...(claimDirectoryProof ? { claimDirectoryProof } : {}), ...(claimOwnerProof ? { claimOwnerProof } : {}),
1132
+ backupInventory, approvedInventory: currentApproved, cleanupArtifacts: record.cleanupArtifacts,
1133
+ recordState, needsGeneration, preservedPaths: classification.preserved };
1134
+ }
1135
+ async function parseCurrentCatalog(root, expected) {
1136
+ const current = await checkedFile(path.join(root, "catalog.json"));
1137
+ if (!compareCatalogIdentity(expected, current.proof))
1138
+ throw new Error("Current catalog differs from its resolution record.");
1139
+ const raw = JSON.parse(current.bytes.toString("utf8"));
1140
+ assertCatalogStructure(raw, 2);
1141
+ const parsed = parseStoredCatalog(raw);
1142
+ if (parsed.version !== 2 || !current.bytes.equals(Buffer.from(`${JSON.stringify(parsed, null, 2)}\n`, "utf8"))) {
1143
+ throw new Error("Resolution record is no longer bound to the exact canonical v2 catalog bytes.");
1144
+ }
1145
+ return parsed;
1146
+ }
1147
+ async function verifyStableTreesFromBaseline(journal, start) {
1148
+ if (stableTreeHash(start, "items") !== journal.stableItemsTree || stableTreeHash(start, "sources") !== journal.stableSourcesTree ||
1149
+ journal.stableSourcesTreeNext !== journal.stableSourcesTree) {
1150
+ throw new Error("Original inventory tree commitments differ from the v5 journal.");
1151
+ }
1152
+ }
1153
+ function isProcessAlive(pid) {
1154
+ try {
1155
+ process.kill(pid, 0);
1156
+ return true;
1157
+ }
1158
+ catch (error) {
1159
+ if (error.code === "ESRCH")
1160
+ return false;
1161
+ return true;
1162
+ }
1163
+ }
1164
+ async function checkProgressArtifacts(root, record, recordState) {
1165
+ const completed = record.progress.completed;
1166
+ const pending = record.progress.pending;
1167
+ for (let index = 0; index < record.cleanupArtifacts.length; index += 1) {
1168
+ const artifact = record.cleanupArtifacts[index];
1169
+ const target = path.join(root, ...artifact.name.split("/"));
1170
+ const exists = !!await statMaybe(target);
1171
+ const shouldBeAbsent = index < completed || index === pending && !exists;
1172
+ if (shouldBeAbsent && exists)
1173
+ return `Completed cleanup artifact reappeared: ${artifact.name}`;
1174
+ if (!shouldBeAbsent && !exists && index !== pending)
1175
+ return `Cleanup artifact disappeared outside the recorded write-ahead prefix: ${artifact.name}`;
1176
+ if (!exists)
1177
+ continue;
1178
+ if (artifact.kind === "file") {
1179
+ const actual = await checkedFile(target);
1180
+ if (actual.proof.mode !== artifact.mode || actual.proof.dev !== artifact.dev || actual.proof.ino !== artifact.ino || actual.proof.bytesHash !== artifact.hash) {
1181
+ return `Cleanup artifact changed from its fixed identity or bytes: ${artifact.name}`;
1182
+ }
1183
+ }
1184
+ else {
1185
+ const actual = await checkedDirectory(target, artifact.mode);
1186
+ if (actual.dev !== artifact.dev || actual.ino !== artifact.ino)
1187
+ return `Cleanup directory identity changed: ${artifact.name}`;
1188
+ const names = (await readdir(target)).sort(compareUtf8);
1189
+ const expected = index === 6
1190
+ ? (await Promise.all(record.cleanupArtifacts.slice(0, 6).map(async (entry) => await statMaybe(path.join(root, ...entry.name.split("/"))) ? path.posix.basename(entry.name) : undefined)))
1191
+ .filter((name) => name !== undefined).sort(compareUtf8)
1192
+ : index === 8 && await statMaybe(path.join(root, ...record.cleanupArtifacts[7].name.split("/"))) ? ["owner.json"] : [];
1193
+ if (canonicalJson(names) !== canonicalJson(expected))
1194
+ return `Cleanup directory has unknown or missing children: ${artifact.name}`;
1195
+ }
1196
+ }
1197
+ const recordNow = await checkedFile(path.join(root, recordName));
1198
+ if (!recordNow.bytes.equals(recordState.raw) || !sameProof(recordNow.proof, recordState.proof))
1199
+ return "Resolution record changed during cleanup verification.";
1200
+ return undefined;
1201
+ }
1202
+ async function readInitialAnalysis(root, reviewId) {
1203
+ try {
1204
+ return await readInitialContext(root, reviewId);
1205
+ }
1206
+ catch (error) {
1207
+ return blocked(root, error instanceof Error ? error.message : "Migration resolution was blocked.", reviewId);
1208
+ }
1209
+ }
1210
+ export async function prepareMigrationResolution(libraryDir) {
1211
+ if (typeof libraryDir !== "string" || libraryDir.length === 0)
1212
+ throw new Error("Migration resolution libraryDir must be a nonempty path string.");
1213
+ const root = path.resolve(libraryDir);
1214
+ const reviewId = randomUUID();
1215
+ const existing = await statMaybe(root);
1216
+ if (!existing)
1217
+ return makePrepared(notNeeded(root, reviewId));
1218
+ let canonical;
1219
+ try {
1220
+ canonical = await readCanonicalRoot(libraryDir);
1221
+ }
1222
+ catch (error) {
1223
+ return makePrepared(blocked(root, error instanceof Error ? error.message : "Migration resolution root is unsafe.", reviewId));
1224
+ }
1225
+ if (!canonical)
1226
+ return makePrepared(notNeeded(root, reviewId));
1227
+ return makePrepared(await readInitialAnalysis(canonical, reviewId));
1228
+ }
1229
+ export function readMigrationResolution(plan) {
1230
+ const state = typeof plan === "object" && plan !== null ? preparedStates.get(plan) : undefined;
1231
+ if (!state || plan.reviewId !== state.view.reviewId)
1232
+ throw new Error("Invalid prepared migration resolution token.");
1233
+ return makeView(state.view.reviewId, state.view.status, state.view);
1234
+ }
1235
+ function snapshotApproval(value, expectedReviewId, sensitivePaths) {
1236
+ const keys = ["reviewId", "confirmed", "approvedSensitivePaths"];
1237
+ if (!exactKeys(value, keys))
1238
+ throw new Error("Migration resolution approval must be a fixed own-data snapshot.");
1239
+ const approval = value;
1240
+ if (approval.reviewId !== expectedReviewId || approval.confirmed !== true || !denseArray(approval.approvedSensitivePaths) ||
1241
+ approval.approvedSensitivePaths.some((entry) => typeof entry !== "string"))
1242
+ throw new Error("Migration resolution approval does not match the reviewed confirmation.");
1243
+ const paths = approval.approvedSensitivePaths;
1244
+ if (new Set(paths).size !== paths.length || canonicalJson([...paths].sort(compareUtf8)) !== canonicalJson([...sensitivePaths].sort(compareUtf8))) {
1245
+ throw new Error("Migration resolution requires individual approval for the exact sensitive path set.");
1246
+ }
1247
+ }
1248
+ function encodeProof(file) {
1249
+ return { rawBase64: file.bytes.toString("base64"), rawHash: sha256(file.bytes), file: file.proof };
1250
+ }
1251
+ async function createTypedClaim(root, journal, lock, resolutionId, journalRawHash, assertRootCurrent) {
1252
+ const claimPath = path.join(root, claimName);
1253
+ await assertRootCurrent();
1254
+ await resolutionCheckpoint?.("before-claim-mkdir", root);
1255
+ await mkdir(claimPath, { mode: 0o700 });
1256
+ await chmod(claimPath, 0o700);
1257
+ await resolutionCheckpoint?.("after-claim-mkdir", root);
1258
+ const owner = { version: 1, mode: "legacy-migration-resolution-claim", root, token: journal.token,
1259
+ resolutionId, nonce: randomUUID(), host: os.hostname(), pid: process.pid, lock, journalRawHash };
1260
+ const bytes = Buffer.from(canonicalJson(owner), "utf8");
1261
+ const temp = path.join(claimPath, "owner.tmp");
1262
+ await assertRootCurrent();
1263
+ const handle = await open(temp, "wx", 0o600);
1264
+ try {
1265
+ await handle.writeFile(bytes);
1266
+ await resolutionCheckpoint?.("after-claim-owner-temp-write", root);
1267
+ await handle.sync();
1268
+ await resolutionCheckpoint?.("after-claim-owner-temp-sync", root);
1269
+ }
1270
+ finally {
1271
+ await handle.close();
1272
+ }
1273
+ const ownerPath = path.join(claimPath, "owner.json");
1274
+ await assertRootCurrent();
1275
+ await link(temp, ownerPath);
1276
+ await resolutionCheckpoint?.("after-claim-owner-install", root);
1277
+ await syncDirectory(claimPath);
1278
+ await resolutionCheckpoint?.("after-claim-directory-sync", root);
1279
+ const ownerFile = await checkedFile(ownerPath);
1280
+ const directory = await checkedDirectory(claimPath, 0o700);
1281
+ if (!ownerFile.bytes.equals(bytes) || ownerFile.proof.mode !== 0o600 || (await readdir(claimPath)).sort(compareUtf8).join("|") !== "owner.json|owner.tmp") {
1282
+ throw new Error("Typed claim installation did not produce its exact durable owner proof.");
1283
+ }
1284
+ await assertRootCurrent();
1285
+ await unlink(temp);
1286
+ await resolutionCheckpoint?.("after-claim-temp-unlink", root);
1287
+ await syncDirectory(claimPath);
1288
+ await syncDirectory(root);
1289
+ await resolutionCheckpoint?.("after-claim-root-sync", root);
1290
+ const finalOwner = await checkedFile(ownerPath);
1291
+ const finalDirectory = await checkedDirectory(claimPath, 0o700);
1292
+ if (!finalOwner.bytes.equals(bytes) || (await readdir(claimPath)).sort(compareUtf8).join("|") !== "owner.json")
1293
+ throw new Error("Typed claim changed after durable installation.");
1294
+ return { directory: finalDirectory, owner: finalOwner.proof, ownerRawBase64: bytes.toString("base64") };
1295
+ }
1296
+ function recordBytes(record) { return Buffer.from(canonicalJson(record), "utf8"); }
1297
+ async function installInitialRecord(root, record, assertRootCurrent) {
1298
+ const bytes = recordBytes(record);
1299
+ const temp = currentRecordTemp(root, record);
1300
+ await assertRootCurrent();
1301
+ const handle = await open(temp, "wx", 0o600);
1302
+ await resolutionCheckpoint?.("before-record-temp-write", root);
1303
+ try {
1304
+ await handle.writeFile(bytes);
1305
+ await resolutionCheckpoint?.("after-record-temp-write", root);
1306
+ await handle.sync();
1307
+ await resolutionCheckpoint?.("after-record-temp-sync", root);
1308
+ }
1309
+ finally {
1310
+ await handle.close();
1311
+ }
1312
+ await assertRootCurrent();
1313
+ await link(temp, path.join(root, recordName));
1314
+ await resolutionCheckpoint?.("after-record-install", root);
1315
+ await syncDirectory(root);
1316
+ await resolutionCheckpoint?.("after-record-root-sync", root);
1317
+ const installed = await checkedFile(path.join(root, recordName));
1318
+ if (!installed.bytes.equals(bytes) || installed.proof.mode !== 0o600)
1319
+ throw new Error("Durable resolution record differs from its approved fixed bytes.");
1320
+ await unlink(temp);
1321
+ await resolutionCheckpoint?.("after-record-temp-unlink", root);
1322
+ await syncDirectory(root);
1323
+ await resolutionCheckpoint?.("after-record-temp-root-sync", root);
1324
+ return { record, raw: bytes, proof: installed.proof };
1325
+ }
1326
+ async function replaceRecord(root, previous, record, assertRootCurrent, updateKind) {
1327
+ const current = await checkedFile(path.join(root, recordName));
1328
+ if (!current.bytes.equals(previous.raw) || !sameProof(current.proof, previous.proof))
1329
+ throw new Error("Resolution record compare-and-swap failed.");
1330
+ const updatedRecord = { ...record, privateRevisionHash: reviewCommitment(record, { rawHash: sha256(previous.raw), proof: previous.proof }) };
1331
+ const bytes = recordBytes(updatedRecord);
1332
+ const temp = currentRecordTemp(root, record);
1333
+ const updatePoints = updateKind === "action-intent"
1334
+ ? { write: "after-action-intent-temp-write", sync: "after-action-intent-temp-sync", install: "after-action-intent-install", rootSync: "after-action-intent-root-sync" }
1335
+ : updateKind === "action-progress"
1336
+ ? { write: "after-action-progress-temp-write", sync: "after-action-progress-temp-sync", install: "after-action-progress-install", rootSync: "after-action-progress-root-sync" }
1337
+ : { write: "after-generation-temp-write", sync: "after-generation-temp-sync", install: "after-generation-replace", rootSync: "after-generation-root-sync" };
1338
+ await assertRootCurrent();
1339
+ const handle = await open(temp, "wx", 0o600);
1340
+ if (updateKind === "action-intent")
1341
+ await resolutionCheckpoint?.("before-action-intent", root);
1342
+ try {
1343
+ await handle.writeFile(bytes);
1344
+ await resolutionCheckpoint?.(updatePoints.write, root);
1345
+ await handle.sync();
1346
+ await resolutionCheckpoint?.(updatePoints.sync, root);
1347
+ }
1348
+ finally {
1349
+ await handle.close();
1350
+ }
1351
+ const second = await checkedFile(path.join(root, recordName));
1352
+ if (!second.bytes.equals(previous.raw) || !sameProof(second.proof, previous.proof))
1353
+ throw new Error("Resolution record changed before atomic progress update.");
1354
+ await assertRootCurrent();
1355
+ await rename(temp, path.join(root, recordName));
1356
+ await resolutionCheckpoint?.(updatePoints.install, root);
1357
+ await syncDirectory(root);
1358
+ await resolutionCheckpoint?.(updatePoints.rootSync, root);
1359
+ const installed = await checkedFile(path.join(root, recordName));
1360
+ if (!installed.bytes.equals(bytes))
1361
+ throw new Error("Atomic resolution record update installed unexpected bytes.");
1362
+ return { record: updatedRecord, raw: bytes, proof: installed.proof };
1363
+ }
1364
+ function withProgress(record, completed, pending) {
1365
+ return { ...record, progress: { phase: progressPhase(completed, pending), completed, pending } };
1366
+ }
1367
+ async function checkLiveRecord(root, recordState) {
1368
+ const live = await readRecord(root);
1369
+ if (!live || !live.raw.equals(recordState.raw) || !sameProof(live.proof, recordState.proof))
1370
+ throw new Error("Migration resolution record changed during cleanup.");
1371
+ }
1372
+ async function verifyApprovedInventory(root, record) {
1373
+ const temp = await checkTemps(root, { record, raw: recordBytes(record), proof: (await checkedFile(path.join(root, recordName))).proof });
1374
+ const current = await scanMigrationInventory(root, { excludedPrefixes: [lockName, claimName, recordName, ...(temp ? [path.basename(temp)] : [])] });
1375
+ const journal = parseStorageJournal(JSON.parse(Buffer.from(record.originalJournal.rawBase64, "base64").toString("utf8")), { root, token: record.token });
1376
+ if (journal.version !== 5)
1377
+ throw new Error("Resolution record no longer has an original v5 journal.");
1378
+ const start = parseMigrationStartInventory(journal.startInventory);
1379
+ const exclusions = backupPayloadExclusions(start, record.backupDir, temp ? [path.basename(temp)] : []);
1380
+ const actual = filteredInventory(current, exclusions);
1381
+ if (!inventoryHashEqual(actual, record.approvedInventory))
1382
+ throw new Error("The approved payload, catalog, or outside inventory changed after resolution record installation.");
1383
+ const catalog = await checkedFile(path.join(root, "catalog.json"));
1384
+ if (!compareCatalogIdentity(record.currentCatalog, catalog.proof))
1385
+ throw new Error("Current v2 catalog differs from its durable resolution record.");
1386
+ const oldCatalog = await originalCatalogFromBackup(root, journal);
1387
+ const nextCatalog = parseStoredCatalog(JSON.parse(catalog.bytes.toString("utf8")));
1388
+ if (nextCatalog.version !== 2)
1389
+ throw new Error("Resolution current catalog is no longer v2.");
1390
+ verifyAllowedPayloadChanges(root, start, current, oldCatalog, nextCatalog, exclusions);
1391
+ const backupInventory = await validateBackup(root, journal, start, current);
1392
+ if (!inventoryHashEqual(backupInventory, record.backupInventory))
1393
+ throw new Error("Permanent verified backup inventory changed during cleanup.");
1394
+ }
1395
+ async function removeAction(root, record, index, allowMissing, assertRootCurrent) {
1396
+ const artifact = record.cleanupArtifacts[index];
1397
+ const target = path.join(root, ...artifact.name.split("/"));
1398
+ const stat = await statMaybe(target);
1399
+ if (!stat) {
1400
+ if (allowMissing)
1401
+ return;
1402
+ throw new Error(`Write-ahead cleanup target is missing before its pending intent: ${artifact.name}`);
1403
+ }
1404
+ if (artifact.kind === "file") {
1405
+ const loaded = await checkedFile(target);
1406
+ if (loaded.proof.mode !== artifact.mode || loaded.proof.dev !== artifact.dev || loaded.proof.ino !== artifact.ino || loaded.proof.bytesHash !== artifact.hash) {
1407
+ throw new Error(`Write-ahead cleanup file differs from its fixed proof: ${artifact.name}`);
1408
+ }
1409
+ await assertRootCurrent();
1410
+ await resolutionCheckpoint?.("before-action-unlink", root);
1411
+ await unlink(target);
1412
+ await resolutionCheckpoint?.("after-action-unlink", root);
1413
+ }
1414
+ else {
1415
+ const directory = await checkedDirectory(target, artifact.mode);
1416
+ if (directory.dev !== artifact.dev || directory.ino !== artifact.ino || (await readdir(target)).length !== 0) {
1417
+ throw new Error(`Write-ahead cleanup directory is not the recorded empty directory: ${artifact.name}`);
1418
+ }
1419
+ await assertRootCurrent();
1420
+ await resolutionCheckpoint?.("before-action-unlink", root);
1421
+ await rmdir(target);
1422
+ await resolutionCheckpoint?.("after-action-unlink", root);
1423
+ }
1424
+ await resolutionCheckpoint?.("before-action-parent-sync", root);
1425
+ await syncDirectory(path.dirname(target));
1426
+ await resolutionCheckpoint?.("after-action-parent-sync", root);
1427
+ }
1428
+ async function updateProgress(root, state, completed, pending, assertRootCurrent, updateKind) {
1429
+ const updated = withProgress(state.record, completed, pending);
1430
+ return replaceRecord(root, state, updated, assertRootCurrent, updateKind);
1431
+ }
1432
+ function directoryActionIndex(index) { return index === 6 || index === 8; }
1433
+ async function continueCleanup(root, initial, assertRootCurrent) {
1434
+ let state = initial;
1435
+ const initialAlias = await checkTemps(root, state);
1436
+ if (initialAlias) {
1437
+ const aliasFile = await checkedFile(initialAlias);
1438
+ if (!sameProof(aliasFile.proof, state.proof) || !aliasFile.bytes.equals(state.raw))
1439
+ throw new Error("Resolution temporary is not an owned completed-record alias.");
1440
+ await assertRootCurrent();
1441
+ await unlink(initialAlias);
1442
+ await syncDirectory(root);
1443
+ }
1444
+ for (;;) {
1445
+ await assertRootCurrent();
1446
+ await checkLiveRecord(root, state);
1447
+ await verifyApprovedInventory(root, state.record);
1448
+ const mismatch = await checkProgressArtifacts(root, state.record, state);
1449
+ if (mismatch)
1450
+ throw new Error(mismatch);
1451
+ const { completed, pending } = state.record.progress;
1452
+ if (completed === 9)
1453
+ break;
1454
+ const index = pending ?? completed;
1455
+ if (index !== completed)
1456
+ throw new Error("Resolution write-ahead progress is not a completed prefix.");
1457
+ if (pending === null)
1458
+ state = await updateProgress(root, state, completed, index, assertRootCurrent, "action-intent");
1459
+ await verifyApprovedInventory(root, state.record);
1460
+ const progressIssue = await checkProgressArtifacts(root, state.record, state);
1461
+ if (progressIssue)
1462
+ throw new Error(progressIssue);
1463
+ await removeAction(root, state.record, index, true, assertRootCurrent);
1464
+ const after = await statMaybe(path.join(root, ...state.record.cleanupArtifacts[index].name.split("/")));
1465
+ if (after)
1466
+ throw new Error("Pending cleanup target reappeared after unlink.");
1467
+ state = await updateProgress(root, state, index + 1, null, assertRootCurrent, "action-progress");
1468
+ if (directoryActionIndex(index))
1469
+ await syncDirectory(root);
1470
+ }
1471
+ await resolutionCheckpoint?.("after-terminal-update", root);
1472
+ await verifyApprovedInventory(root, state.record);
1473
+ const mismatch = await checkProgressArtifacts(root, state.record, state);
1474
+ if (mismatch)
1475
+ throw new Error(mismatch);
1476
+ await checkLiveRecord(root, state);
1477
+ const alias = await checkTemps(root, state);
1478
+ if (alias) {
1479
+ const aliasFile = await checkedFile(alias);
1480
+ if (!sameProof(aliasFile.proof, state.proof) || !aliasFile.bytes.equals(state.raw))
1481
+ throw new Error("Resolution temp alias changed before terminal cleanup.");
1482
+ await assertRootCurrent();
1483
+ await unlink(alias);
1484
+ await syncDirectory(root);
1485
+ }
1486
+ const recordFile = await checkedFile(path.join(root, recordName));
1487
+ if (!recordFile.bytes.equals(state.raw) || !sameProof(recordFile.proof, state.proof))
1488
+ throw new Error("Terminal resolution record changed before final removal.");
1489
+ await assertRootCurrent();
1490
+ await resolutionCheckpoint?.("before-record-unlink", root);
1491
+ await unlink(path.join(root, recordName));
1492
+ await resolutionCheckpoint?.("after-record-unlink", root);
1493
+ await syncDirectory(root);
1494
+ await resolutionCheckpoint?.("after-record-unlink-root-sync", root);
1495
+ }
1496
+ /** @internal Cooperating ordinary storage readers/writers call this while holding the O_EXLOCK root lease. */
1497
+ export async function resumeCompletedMigrationResolution(root, assertRootCurrent = async () => undefined) {
1498
+ await assertRootCurrent();
1499
+ const record = await readRecord(root);
1500
+ const decodedNames = await listManagedMigrationRootEntries(root);
1501
+ const unknownTemps = decodedNames.filter(isRecordTempName);
1502
+ if (!record) {
1503
+ if (await statMaybe(path.join(root, claimName)) || unknownTemps.length > 0)
1504
+ throw new Error("Unowned migration resolution claim or temporary record exists; preserving it.");
1505
+ return;
1506
+ }
1507
+ const temp = await checkTemps(root, record);
1508
+ const analysis = await readRecordedContext(root, randomUUID(), record, temp);
1509
+ if (analysis.status !== "ready" || analysis.needsGeneration)
1510
+ throw new Error("A recorded migration resolution differs from its approved fixed state; explicit fresh review and approval are required.");
1511
+ if (temp) {
1512
+ const alias = await checkedFile(temp);
1513
+ if (!sameProof(alias.proof, record.proof) || !alias.bytes.equals(record.raw))
1514
+ throw new Error("Resolution temporary is not an owned completed-record alias.");
1515
+ await unlink(temp);
1516
+ await syncDirectory(root);
1517
+ }
1518
+ await continueCleanup(root, record, assertRootCurrent);
1519
+ }
1520
+ export async function commitMigrationResolution(plan, approval) {
1521
+ const state = typeof plan === "object" && plan !== null ? preparedStates.get(plan) : undefined;
1522
+ if (!state || plan.reviewId !== state.view.reviewId)
1523
+ throw new Error("Invalid prepared migration resolution token.");
1524
+ if (state.used)
1525
+ throw new Error("Prepared migration resolution was already consumed; prepare a new review.");
1526
+ state.used = true;
1527
+ if (state.view.status !== "ready")
1528
+ throw new Error(`Migration resolution is ${state.view.status}; it cannot be committed.`);
1529
+ snapshotApproval(approval, state.view.reviewId, state.view.sensitivePaths);
1530
+ const lease = await acquireRootLease(state.root, 5_000);
1531
+ try {
1532
+ await lease.assertCurrent();
1533
+ await resolutionCheckpoint?.("lease-acquired", state.root);
1534
+ const current = await readInitialAnalysis(state.root, state.view.reviewId);
1535
+ if (current.status !== "ready" || current.revision !== state.revision)
1536
+ throw new Error("Migration resolution state changed after review; prepare a fresh review before approval.");
1537
+ if (state.analysis.recordState) {
1538
+ const previous = current.recordState;
1539
+ if (!previous)
1540
+ throw new Error("Reviewed resolution record disappeared before commit.");
1541
+ if (current.needsGeneration) {
1542
+ const generation = previous.record.generation + 1;
1543
+ if (!Number.isSafeInteger(generation))
1544
+ throw new Error("Migration resolution generation is exhausted.");
1545
+ const currentRecord = previous.record;
1546
+ const base = {
1547
+ ...currentRecord,
1548
+ generation,
1549
+ predecessorHash: sha256(previous.raw),
1550
+ reviewId: state.view.reviewId,
1551
+ currentCatalog: current.currentCatalogProof,
1552
+ sensitivePaths: [...state.view.sensitivePaths],
1553
+ approvedInventory: current.approvedInventory,
1554
+ progress: currentRecord.progress,
1555
+ };
1556
+ const record = { ...base, privateRevisionHash: "" };
1557
+ record.privateRevisionHash = reviewCommitment(base, { rawHash: sha256(previous.raw), proof: previous.proof });
1558
+ const replaced = await replaceRecord(state.root, previous, record, () => lease.assertCurrent(), "generation");
1559
+ await continueCleanup(state.root, replaced, () => lease.assertCurrent());
1560
+ }
1561
+ else {
1562
+ await continueCleanup(state.root, previous, () => lease.assertCurrent());
1563
+ }
1564
+ return { catalogVersion: 2, backupDir: current.journal.backupDir, preservedPaths: [...current.view.preservedPaths] };
1565
+ }
1566
+ const journal = current.journal;
1567
+ const lockProof = current.lockProof;
1568
+ const resolutionId = randomUUID();
1569
+ const claim = await createTypedClaim(state.root, journal, lockProof, resolutionId, sha256(current.journalRaw), () => lease.assertCurrent());
1570
+ const liveLock = await checkedDirectory(path.join(state.root, lockName), 0o700);
1571
+ if (!sameProof(liveLock, lockProof))
1572
+ throw new Error("Migration lock directory identity changed after the readonly resolution review.");
1573
+ const lockNames = (await readdir(path.join(state.root, lockName))).sort(compareUtf8);
1574
+ if (canonicalJson(lockNames) !== canonicalJson(["held-catalog", "journal.digest", "journal.json", "next-catalog", "old-catalog", "owner.json"])) {
1575
+ throw new Error("Migration lock changed its exact artifact membership after the readonly resolution review.");
1576
+ }
1577
+ const lockFiles = await Promise.all([
1578
+ checkedFile(path.join(state.root, lockName, "held-catalog")),
1579
+ checkedFile(path.join(state.root, lockName, "old-catalog")),
1580
+ checkedFile(path.join(state.root, lockName, "next-catalog")),
1581
+ checkedFile(path.join(state.root, lockName, "journal.digest")),
1582
+ checkedFile(path.join(state.root, lockName, "journal.json")),
1583
+ checkedFile(path.join(state.root, lockName, "owner.json")),
1584
+ ]);
1585
+ const lockArtifacts = initialArtifacts(liveLock, lockFiles.map((file) => file.proof));
1586
+ if (canonicalJson(lockArtifacts.slice(0, 6)) !== canonicalJson(current.cleanupArtifacts) ||
1587
+ !lockFiles[4].bytes.equals(current.journalRaw) || !lockFiles[5].bytes.equals(current.ownerRaw)) {
1588
+ throw new Error("Migration journal, owner, stages, or lock files changed under the resolution review.");
1589
+ }
1590
+ const artifacts = [...lockArtifacts, ...claimArtifacts(claim.directory, claim.owner)];
1591
+ const previousInventory = current.approvedInventory;
1592
+ const currentAll = await scanMigrationInventory(state.root, { excludedPrefixes: [lockName, claimName, recordName] });
1593
+ const startInventory = parseMigrationStartInventory(journal.startInventory);
1594
+ const approvedInventory = filteredInventory(currentAll, backupPayloadExclusions(startInventory, journal.backupDir));
1595
+ if (!inventoryHashEqual(approvedInventory, previousInventory))
1596
+ throw new Error("Migration payload changed while the typed claim was installed.");
1597
+ const verifiedBackup = await validateBackup(state.root, journal, startInventory, currentAll);
1598
+ if (!inventoryHashEqual(verifiedBackup, current.backupInventory))
1599
+ throw new Error("Migration backup custody changed while the typed claim was installed.");
1600
+ const recordDraft = {
1601
+ version: 1, mode: "legacy-migration-resolution", root: state.root, token: journal.token, resolutionId,
1602
+ generation: 1, predecessorHash: null,
1603
+ originalJournal: encodeProof({ bytes: current.journalRaw, proof: (await checkedFile(path.join(state.root, lockName, "journal.json"))).proof }),
1604
+ oldCatalog: journal.oldCatalog, newCatalog: journal.newCatalog, currentCatalog: current.currentCatalogProof,
1605
+ backupDir: journal.backupDir, backupManifest: journal.backupManifest, backupInventory: current.backupInventory,
1606
+ reviewId: state.view.reviewId, confirmed: true, sensitivePaths: [...state.view.sensitivePaths], approvedInventory,
1607
+ originalOwner: encodeProof({ bytes: current.ownerRaw, proof: current.ownerProof }), lock: lockProof,
1608
+ claim, cleanupArtifacts: artifacts, progress: { phase: "artifacts", completed: 0, pending: null },
1609
+ };
1610
+ const record = { ...recordDraft, privateRevisionHash: reviewCommitment(recordDraft, null) };
1611
+ const installed = await installInitialRecord(state.root, record, () => lease.assertCurrent());
1612
+ await continueCleanup(state.root, installed, () => lease.assertCurrent());
1613
+ return { catalogVersion: 2, backupDir: journal.backupDir, preservedPaths: [...state.view.preservedPaths] };
1614
+ }
1615
+ finally {
1616
+ await lease.close();
1617
+ }
1618
+ }