ruvnet-brain 4.3.37 → 4.3.39

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 (41) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +32 -9
  3. package/kb/forge-update.mjs +1884 -0
  4. package/package.json +3 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/hooks/codex-hooks.json +4 -4
  8. package/plugin/hooks/hook-contracts.json +7 -7
  9. package/plugin/hooks/hooks.json +2 -2
  10. package/plugin/scripts/capability-claim-evidence.mjs +11 -2
  11. package/plugin/scripts/capacity-aware-parallel-work.mjs +71 -15
  12. package/plugin/scripts/codex-hook-wrapper.mjs +3 -0
  13. package/plugin/scripts/completion-claim-evidence.mjs +262 -0
  14. package/plugin/scripts/continuation-gate.mjs +105 -21
  15. package/plugin/scripts/continuation-objective.mjs +15 -0
  16. package/plugin/scripts/decision-gate.mjs +22 -2
  17. package/plugin/scripts/duplicate-gate.mjs +503 -0
  18. package/plugin/scripts/grounding-turn-evidence.mjs +339 -0
  19. package/plugin/scripts/grounding-turn-gate.mjs +77 -25
  20. package/plugin/scripts/grounding-turn-mark.mjs +61 -14
  21. package/plugin/scripts/hook-input.mjs +15 -0
  22. package/plugin/scripts/host-update.mjs +45 -0
  23. package/plugin/scripts/nightly-scheduler.mjs +34 -0
  24. package/plugin/scripts/session-start-budget.mjs +1 -0
  25. package/plugin/scripts/session-start-core.mjs +15 -2
  26. package/plugin/scripts/session-start-health.mjs +101 -1
  27. package/plugin/scripts/session-start-update-plane.mjs +71 -1
  28. package/scripts/approved-runtime.mjs +1 -1
  29. package/scripts/completion-claim-replay.mjs +114 -0
  30. package/scripts/corpus-canary.mjs +396 -0
  31. package/scripts/corpus-dispatch-decision.mjs +2 -2
  32. package/scripts/corpus-promotion.mjs +49 -0
  33. package/scripts/corpus-reconcile.mjs +27 -3
  34. package/scripts/corpus-watchdog.mjs +45 -6
  35. package/scripts/duplicate-gate-replay.mjs +98 -0
  36. package/scripts/grounding-turn-replay.mjs +131 -0
  37. package/scripts/nightly-watchdog.mjs +3 -3
  38. package/scripts/protected-release-invocation.mjs +1 -1
  39. package/scripts/release.mjs +180 -77
  40. package/scripts/single-source-check.mjs +12 -6
  41. package/scripts/wired-check.mjs +2 -0
@@ -0,0 +1,1884 @@
1
+ #!/usr/bin/env node
2
+ // forge-update.mjs — GENERALIZED EVERGREEN self-updater for any rvf-kb-forge bundle.
3
+ //
4
+ // Ships INSIDE the bundle next to SOURCE.json (written by forge-build.mjs with --canonical-url).
5
+ // A consumer who copied the bundle runs it in that dir. It reads the embedded provenance
6
+ // (SOURCE.json — "where I came from"), fetches the LIVE canonical build manifest, and reports
7
+ // whether their copy is current; --apply downloads + extracts + re-verifies with forge-guard.mjs.
8
+ //
9
+ // node forge-update.mjs (== --check) report only: UP TO DATE / BEHIND
10
+ // node forge-update.mjs --check same as above
11
+ // node forge-update.mjs --apply download canonical bundle, back up, extract over local,
12
+ // re-verify with forge-guard.mjs, print DONE
13
+ // node forge-update.mjs <name> limit to one store when SOURCE.json carries several
14
+ //
15
+ // Cron example (Mon 09:00, log result):
16
+ // 0 9 * * 1 cd /path/to/kb && /usr/bin/node forge-update.mjs --check >> forge-update.log 2>&1
17
+ //
18
+ // Zero dependencies. Node 18+ (global fetch). Network failures fail LOUD and CLEAN: clear
19
+ // message, non-zero exit, NO partial clobber. If --canonical-url was not set at build time the
20
+ // URLs are null and this prints a clear "self-update not configured for this build" message.
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import os from 'node:os';
25
+ import { execFileSync } from 'node:child_process';
26
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
+ import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto';
28
+ import { extractZip } from './zip-extract.mjs';
29
+ import { applyBrainProfile, discoverStoreFamilies, readBrainProfile } from './brain-profile.mjs';
30
+ import { acquireRefreshLock, releaseRefreshLock } from './refresh-run.mjs';
31
+ import { runStorageTransaction, treeIdentity, managedStorageInventory, storageDelta } from './update-storage-transaction.mjs';
32
+ import { pruneLifecycleEvidence } from './lifecycle-evidence-retention.mjs';
33
+ import {
34
+ isCorpusReleaseTag, assertCorpusReleaseCompatible, readInstalledRuntime,
35
+ recordCorpusTransportIdentity, recordCorpusGenerationIdentity,
36
+ readRejectedRelease, writeRejectedRelease, clearRejectedRelease,
37
+ releaseKind, parseCorpusGeneration,
38
+ } from './corpus-release-identity.mjs';
39
+
40
+ const KB_DIR = path.dirname(fileURLToPath(import.meta.url));
41
+ const SOURCE_PATH = path.join(KB_DIR, 'SOURCE.json');
42
+
43
+ const argv = process.argv.slice(2);
44
+ const APPLY = argv.includes('--apply');
45
+ const RESTORE_COMPLETE = argv.includes('--restore-complete');
46
+ // `--staged-release <descriptor.json>` is the private-overlay recovery rail's own entry point
47
+ // (applyVerifiedStagedRelease, invoked by bin/install.mjs when the normal --apply flow above cannot
48
+ // complete). It bypasses main() and this module's own SOURCE.json/canonicalManifestUrl bootstrap
49
+ // entirely — discovery is supplied by the descriptor, not read from this KB tree.
50
+ const stagedReleaseIndex = argv.indexOf('--staged-release');
51
+ const STAGED_RELEASE_FILE = stagedReleaseIndex >= 0 && argv[stagedReleaseIndex + 1]
52
+ ? path.resolve(argv[stagedReleaseIndex + 1]) : null;
53
+ const resultFileIndex = argv.indexOf('--result-file');
54
+ const RESULT_FILE = resultFileIndex >= 0 && argv[resultFileIndex + 1]
55
+ ? path.resolve(argv[resultFileIndex + 1]) : (process.env.RUVNET_UPDATE_RESULT ? path.resolve(process.env.RUVNET_UPDATE_RESULT) : null);
56
+ const optionValueIndexes = new Set([
57
+ ...(resultFileIndex >= 0 ? [resultFileIndex + 1] : []),
58
+ ...(stagedReleaseIndex >= 0 ? [stagedReleaseIndex + 1] : []),
59
+ ]);
60
+ const ONLY = argv.find((a, index) => !a.startsWith('--') && !optionValueIndexes.has(index));
61
+
62
+ /**
63
+ * EXIT CODES — anything scripting this (a cron line, a LaunchAgent, `npx ruvnet-brain --update`)
64
+ * reads only this number, so each one means exactly one thing:
65
+ *
66
+ * 0 --check: current · --apply: explicit applied or byte-exact noop result receipt
67
+ * 1 configuration/verification error; local copy may need the rollback beside it
68
+ * 2 network / canonical manifest unreachable — nothing was touched
69
+ * 3 the signature could not be fetched — refused to apply
70
+ * 4 signature verification FAILED — refused to apply
71
+ * 10 --check: a newer build exists
72
+ */
73
+
74
+ // The updater's trust root is part of the executable, not part of either the currently installed KB
75
+ // or the downloaded candidate. A missing auxiliary verifier/key must therefore never create a
76
+ // bootstrap bypass. Keep this byte-identical to keys/ruvnet-brain-signing.pub.pem; the release gate
77
+ // checks that identity.
78
+ const SIGNING_PUBKEY_PEM = `-----BEGIN PUBLIC KEY-----
79
+ MCowBQYDK2VwAyEAgse9TAtehXUvUfTrJFY2CCHiCbmelR8yCgS//sen5/w=
80
+ -----END PUBLIC KEY-----`;
81
+
82
+ export function verifyDownloadedBundle(bundlePath, signaturePath) {
83
+ try {
84
+ if (!fs.existsSync(bundlePath)) return { ok: false, reason: `bundle not found: ${bundlePath}` };
85
+ if (!fs.existsSync(signaturePath)) return { ok: false, reason: 'signature missing (fail-closed)' };
86
+ const digest = createHash('sha256').update(fs.readFileSync(bundlePath)).digest('hex');
87
+ const ok = verifySignature(null, Buffer.from(digest, 'hex'), createPublicKey(SIGNING_PUBKEY_PEM), fs.readFileSync(signaturePath));
88
+ return ok ? { ok: true, reason: `signature valid (sha256 ${digest.slice(0, 12)}…)` }
89
+ : { ok: false, reason: 'signature does NOT match — bundle may be tampered' };
90
+ } catch (error) { return { ok: false, reason: `verify error: ${error.message}` }; }
91
+ }
92
+
93
+ // ── ROLLBACK COPIES — ONE settlement point, on EVERY exit path ────────────────────────────────
94
+ // `process.exit()` does NOT run `finally` blocks, so a die() anywhere below the directory swap used
95
+ // to leave a multi-gigabyte rollback copy behind with nothing to release it. Issue #108 measured
96
+ // exactly that: ~1.6 GB stranded per night, ten copies (~16 GB) before the owner noticed — and the
97
+ // run that stranded them had actually SUCCEEDED. Every exit now passes through settleRollback(), so
98
+ // the copy is either RELEASED or deliberately KEPT and named. Never silently stranded.
99
+ const backupsMade = [];
100
+ let rollbackSettled = false;
101
+ let updateLock = null;
102
+ let updateOutcomeWritten = false;
103
+ let lifecycleRetention = null;
104
+ let legacyBackupRetention = null;
105
+
106
+ function writeUpdateOutcome(outcome) {
107
+ if (updateOutcomeWritten) return outcome;
108
+ if (legacyBackupRetention) outcome = { ...outcome, legacyBackupRetention };
109
+ if (!lifecycleRetention) {
110
+ try {
111
+ lifecycleRetention = pruneLifecycleEvidence({ brainHome: path.dirname(KB_DIR), kbDir: KB_DIR,
112
+ preserveRefreshRunIds: updateLock?.runId ? [updateLock.runId] : [],
113
+ preserveTransactionPaths: outcome.transactionReceipts ? [outcome.transactionReceipts] : [] });
114
+ } catch (error) {
115
+ lifecycleRetention = { schemaVersion: 1, kind: 'ruvnet-brain-lifecycle-evidence-retention',
116
+ withinBudget: false, unsafe: [{ path: path.dirname(KB_DIR), reason: error.message }] };
117
+ }
118
+ }
119
+ const finalOutcome = lifecycleRetention.withinBudget === true ? { ...outcome, lifecycleRetention }
120
+ : { ...outcome, terminalVerdict: 'recovery-required', exitCode: 1,
121
+ reason: `lifecycle evidence retention failed: ${lifecycleRetention.unsafe?.map(({ reason }) => reason).join('; ') || 'budget exceeded'}`,
122
+ lifecycleRetention };
123
+ if (!RESULT_FILE) return finalOutcome;
124
+ fs.mkdirSync(path.dirname(RESULT_FILE), { recursive: true });
125
+ atomicJson(RESULT_FILE, { schemaVersion: 1, kind: 'ruvnet-brain-update-result',
126
+ recordedAt: new Date().toISOString(), ...finalOutcome });
127
+ updateOutcomeWritten = true;
128
+ return finalOutcome;
129
+ }
130
+
131
+ // `--check` is a deliberately lightweight, side-effect-free poll: no lock, no rollback preflight, no
132
+ // candidate ever built. writeUpdateOutcome() cannot be reused for it — that function always runs
133
+ // pruneLifecycleEvidence() (a real filesystem GC pass) and references updateLock/legacyBackupRetention,
134
+ // both of which are apply-only concepts. This records ONLY the currency verdict, and only when the
135
+ // caller actually asked for a result (--result-file / RUVNET_UPDATE_RESULT) — matching --check's
136
+ // existing "does nothing unless asked" contract. S2: this is what lets --apply, bin/install.mjs, and
137
+ // the session-start banner read the SAME recorded verdict a --check run (e.g. the SessionStart
138
+ // heartbeat's detached poll) already produced, instead of each re-deriving their own comparison.
139
+ function writeCheckOutcome(outcome) {
140
+ const finalOutcome = { schemaVersion: 1, kind: 'ruvnet-brain-check-result', mode: 'check',
141
+ recordedAt: new Date().toISOString(), ...outcome };
142
+ if (!RESULT_FILE) return finalOutcome;
143
+ fs.mkdirSync(path.dirname(RESULT_FILE), { recursive: true });
144
+ atomicJson(RESULT_FILE, finalOutcome);
145
+ return finalOutcome;
146
+ }
147
+
148
+ export function acquireUpdateLock({ kbDir = KB_DIR, pid = process.pid, isAlive } = {}) {
149
+ return acquireRefreshLock({ kbDir, brainHome: path.dirname(path.resolve(kbDir)), action: 'update', pid,
150
+ ...(isAlive === undefined ? {} : { isAlive }) });
151
+ }
152
+
153
+ export function releaseUpdateLock(lock = updateLock) {
154
+ if (!lock) return false;
155
+ const released = releaseRefreshLock(lock);
156
+ if (released && lock === updateLock) updateLock = null;
157
+ return released;
158
+ }
159
+
160
+ process.on('exit', () => { releaseUpdateLock(); });
161
+ function settleRollback({ reclaimable, keepReason = null, intentionallyRemovedStores = [] }) {
162
+ if (rollbackSettled) return;
163
+ rollbackSettled = true;
164
+ if (!reclaimable) {
165
+ // A rollback copy is only dead weight once the copy in place is known good. When it is NOT,
166
+ // this directory is the user's recovery — deleting it to "not strand resources" would be the
167
+ // far worse bug. Keep it, and say where it is and why.
168
+ for (const b of backupsMade) {
169
+ try { writeSnapshotReceipt(b, { state: 'RETAINED', reason: keepReason || 'live KB could not be verified' }); }
170
+ catch { /* the original update failure remains authoritative */ }
171
+ console.error(`\n ROLLBACK COPY KEPT: ${b}`);
172
+ console.error(` ${keepReason || 'the copy now in place could not be verified — restore this directory if the KB is broken,'}`);
173
+ console.error(` then remove it once you are satisfied (or re-run this updater after fixing the cause).`);
174
+ }
175
+ return;
176
+ }
177
+ const { removed, kept, freed } = reclaimBackups({ kbDir: KB_DIR, backupsMade, intentionallyRemovedStores });
178
+ if (removed.length) {
179
+ console.log(`\nreleased ${removed.length} rollback ${removed.length === 1 ? 'copy' : 'copies'} — ${(freed / 1e9).toFixed(2)} GB reclaimed`);
180
+ console.log(` (the copy in place is intact; this exact build is re-downloadable at any time)`);
181
+ }
182
+ for (const [b, why] of kept) console.log(`\n KEPT ${b}\n ${why}`);
183
+ }
184
+
185
+ function die(msg, code = 1) {
186
+ console.error(`\n[forge-update] ERROR: ${msg}`);
187
+ try { writeUpdateOutcome({ terminalVerdict: 'failed', exitCode: code, reason: msg }); } catch { /* primary error wins */ }
188
+ // Cleanup must never mask the error that caused it.
189
+ try { settleRollback({ reclaimable: false }); } catch { /* ignore */ }
190
+ process.exit(code);
191
+ }
192
+
193
+ if (!fs.existsSync(SOURCE_PATH) && !STAGED_RELEASE_FILE) {
194
+ die(`no SOURCE.json next to this script (${SOURCE_PATH}). This bundle predates the evergreen ` +
195
+ `mechanism or SOURCE.json was removed. Re-download a current bundle to gain self-update.`);
196
+ }
197
+ let source;
198
+ try { source = fs.existsSync(SOURCE_PATH) ? JSON.parse(fs.readFileSync(SOURCE_PATH, 'utf8')) : {}; }
199
+ catch (e) {
200
+ // --staged-release never reads this module-level `source` (its own descriptor supplies liveDir/
201
+ // stagedDir explicitly); a corrupt SOURCE.json in whatever directory happens to be current when
202
+ // this script is invoked as a plain recovery executable must not block that rail.
203
+ if (!STAGED_RELEASE_FILE) die(`SOURCE.json is unreadable/corrupt: ${e.message}`);
204
+ source = {};
205
+ }
206
+
207
+ // The RELEASE TAG IS A PROPERTY OF THE BUNDLE, and every store inside it shares that tag (issue
208
+ // #108 bug 2). It is written once, at the top level of SOURCE.json; the per-store entries never
209
+ // carry it. isBehind() short-circuits on `canon.releaseTag && local.releaseTag`, so with the local
210
+ // side always undefined that branch could never fire — every store fell through to a timestamp
211
+ // compare against the RELEASE's publish time, which is always later than the forge time of the KB
212
+ // inside it. Result: all 15 stores read BEHIND on every run, forever, immediately after a
213
+ // successful update. `--check` exited 10 permanently and was useless as a monitoring signal, and
214
+ // `--apply` re-downloaded half a gigabyte every night to change nothing. Inheriting the tag the
215
+ // bundle already records is the whole fix; a store that carries its own still wins.
216
+ // The CORPUS transport tag is a bundle property in exactly the same way, and for the same reason:
217
+ // every store in a corpus release arrived in the same archive. It lives in its own field because it
218
+ // is a different identity domain from `releaseTag` — see kb/corpus-release-identity.mjs.
219
+ const withBundleTag = (s) => {
220
+ if (!s) return s;
221
+ let out = s;
222
+ if (out.releaseTag == null && source.releaseTag != null) out = { ...out, releaseTag: source.releaseTag };
223
+ if (out.corpusReleaseTag == null && source.corpusReleaseTag != null) {
224
+ out = { ...out, corpusReleaseTag: source.corpusReleaseTag };
225
+ }
226
+ return out;
227
+ };
228
+ const stores = (Array.isArray(source.stores)
229
+ ? source.stores
230
+ : (source.stores && typeof source.stores === 'object')
231
+ ? Object.entries(source.stores).map(([kbName, v]) => ({ kbName, ...v }))
232
+ : [source]).map(withBundleTag);
233
+
234
+ /**
235
+ * Return only stores the public evergreen updater is allowed to replace.
236
+ *
237
+ * Private deployment overlays still belong in SOURCE.json for provenance, but they do not have a
238
+ * public release asset. `updateManaged: false` keeps those entries visible while preventing a
239
+ * public bundle update from treating them as downloadable targets.
240
+ */
241
+ export function selectUpdateManagedStores(allStores, activeProfile = 'complete') {
242
+ const managed = (Array.isArray(allStores) ? allStores : [])
243
+ .filter((store) => store?.updateManaged !== false);
244
+ return activeProfile === 'ruvector'
245
+ ? managed.filter((store) => store.kbName === 'ruvector')
246
+ : managed;
247
+ }
248
+
249
+ function sameJson(left, right) {
250
+ return JSON.stringify(left) === JSON.stringify(right);
251
+ }
252
+
253
+ function atomicJson(file, value) {
254
+ const temp = `${file}.tmp-${process.pid}`;
255
+ fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`);
256
+ fs.renameSync(temp, file);
257
+ }
258
+
259
+ function cardSections(markdown) {
260
+ const sections = new Map();
261
+ const matches = [...String(markdown || '').matchAll(/^## ([^\n]+)\n/gm)];
262
+ for (let index = 0; index < matches.length; index++) {
263
+ const start = matches[index].index;
264
+ const end = matches[index + 1]?.index ?? markdown.length;
265
+ sections.set(matches[index][1].trim(), markdown.slice(start, end).trimEnd());
266
+ }
267
+ return sections;
268
+ }
269
+
270
+ function mergePrivateEntries(publicEntries, privateEntries, label) {
271
+ const merged = { ...(publicEntries || {}) };
272
+ for (const [name, entry] of Object.entries(privateEntries || {})) {
273
+ if (Object.hasOwn(merged, name) && !sameJson(merged[name], entry)) {
274
+ throw new Error(`${label} collision for private store ${name}`);
275
+ }
276
+ merged[name] = entry;
277
+ }
278
+ return merged;
279
+ }
280
+
281
+ function sha256File(file) {
282
+ return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
283
+ }
284
+
285
+ async function loadTrustedCoverageValidator() {
286
+ // The recovery rail (applyVerifiedStagedRelease) can run from the installer's OWN repo checkout
287
+ // (bin/install.mjs's `REPO_ROOT/kb/forge-update.mjs`) rather than an installed KB tree, where the
288
+ // validator lives at its source location (plugin/scripts/) instead of beside this script.
289
+ const validatorPath = fs.existsSync(path.join(KB_DIR, 'coverage-integrity.mjs'))
290
+ ? path.join(KB_DIR, 'coverage-integrity.mjs')
291
+ : path.join(path.dirname(KB_DIR), 'plugin', 'scripts', 'coverage-integrity.mjs');
292
+ if (!fs.existsSync(validatorPath)) {
293
+ throw new Error('installed coverage validator is missing; re-run the current installer before self-update');
294
+ }
295
+ const validator = await import(pathToFileURL(validatorPath).href);
296
+ if (typeof validator.validateCoverageDirectory !== 'function') {
297
+ throw new Error('installed coverage validator has no validateCoverageDirectory export');
298
+ }
299
+ return validator.validateCoverageDirectory;
300
+ }
301
+
302
+ /**
303
+ * `expectedVersionOverride` is how "never silently install incompatible code" is actually enforced.
304
+ *
305
+ * Read from the tree's OWN SOURCE.json, `expectedVersion` is self-referential: a bundle asserting
306
+ * its own version proves nothing, which is exactly Dual's "preserving a version string alone is
307
+ * insufficient". For a CORPUS release the caller passes the version of the approved runtime this
308
+ * machine is measurably running (kb/corpus-release-identity.mjs re-hashes its executables), so the
309
+ * staged tree is judged against the client, not against itself.
310
+ */
311
+ function validateReleaseCoverageTree(root, validateCoverageDirectory, expectedVersionOverride = null) {
312
+ let expectedVersion = expectedVersionOverride;
313
+ if (expectedVersion == null) {
314
+ try { expectedVersion = JSON.parse(fs.readFileSync(path.join(root, 'SOURCE.json'), 'utf8')).brainVersion || null; }
315
+ catch (error) { return { valid: false, failures: [`SOURCE.json is unreadable: ${error.message}`] }; }
316
+ }
317
+ return validateCoverageDirectory(root, { expectedVersion });
318
+ }
319
+
320
+ /**
321
+ * THE PRIVATE-OVERLAY RECOVERY RAIL. Apply an already-authenticated release staged by the installer
322
+ * (bin/install.mjs's stageBundleForRecovery), for installations whose embedded canonicalManifestUrl
323
+ * is dead/missing or whose own updater otherwise cannot complete `main()`'s normal --apply flow.
324
+ * Discovery (which release, which bytes) is supplied by the caller; trust and activation remain
325
+ * owned by this package.
326
+ *
327
+ * ONE APPLY PATH (S1/S2): this reuses the exact same primitives main() uses for a normal apply —
328
+ * runStorageTransaction for the atomic candidate/rollback swap, restorePrivateFilesIntoCandidate for
329
+ * copying the private overlay onto the candidate, and recordCorpusTransportIdentity/
330
+ * recordCorpusGenerationIdentity for the currency stamps — rather than a second, parallel
331
+ * implementation of "how a private overlay survives an apply." It exists as a SEPARATE ENTRY POINT
332
+ * because it solves a different problem (recovery when the normal polling path cannot run at all,
333
+ * not a routine currency check), not because it needs its own apply mechanics.
334
+ */
335
+ export async function applyVerifiedStagedRelease({
336
+ stagedDir, liveDir, bundlePath, signaturePath, transactionId = `${Date.now()}-${process.pid}`,
337
+ trustedRuntimeDir = KB_DIR, expectedRuntimeVersion = null, releaseTag = null, corpusGeneration = null,
338
+ bundleSha256 = null, packageIdentity = null, stageReceiptPath = `${stagedDir}.staged-release.json`,
339
+ validateCoverageDirectory = null,
340
+ }) {
341
+ const staged = path.resolve(stagedDir);
342
+ const live = path.resolve(liveDir);
343
+ if (!bundlePath || !signaturePath) throw new Error('staged recovery requires bundle and detached signature paths');
344
+ const signature = verifyDownloadedBundle(path.resolve(bundlePath), path.resolve(signaturePath));
345
+ if (!signature.ok) throw new Error(`staged release signature verification failed: ${signature.reason}`);
346
+ const actualBundleSha256 = sha256File(path.resolve(bundlePath));
347
+ if (bundleSha256 && actualBundleSha256 !== bundleSha256) {
348
+ throw new Error(`staged release bundle digest ${actualBundleSha256} differs from sealed identity ${bundleSha256}`);
349
+ }
350
+ if (packageIdentity != null && typeof packageIdentity !== 'string') {
351
+ throw new Error('staged recovery packageIdentity must be an immutable string');
352
+ }
353
+ if (!expectedRuntimeVersion || typeof expectedRuntimeVersion !== 'string') {
354
+ throw new Error('staged recovery requires the expected approved runtime version');
355
+ }
356
+ if (path.resolve(trustedRuntimeDir) !== KB_DIR) {
357
+ throw new Error('staged recovery trust root must be the executing package root');
358
+ }
359
+ const stageReceiptFile = path.resolve(stageReceiptPath);
360
+ if (!fs.existsSync(stageReceiptFile)) throw new Error('staged recovery authentication receipt is missing');
361
+ const stageReceipt = JSON.parse(fs.readFileSync(stageReceiptFile, 'utf8'));
362
+ if (stageReceipt.bundleSha256 !== actualBundleSha256) {
363
+ throw new Error('staged recovery directory is not bound to the signed bundle');
364
+ }
365
+ // The receipt is diagnostic only: bind the candidate cryptographically by independently
366
+ // extracting the authenticated archive and comparing the complete staged tree identity.
367
+ const proofRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-staged-proof-'));
368
+ try {
369
+ await extractZip(path.resolve(bundlePath), proofRoot);
370
+ const nested = path.join(proofRoot, 'ruvnet-brain');
371
+ if (fs.existsSync(path.join(nested, 'forge-mcp-all.mjs'))) {
372
+ for (const entry of fs.readdirSync(nested)) fs.renameSync(path.join(nested, entry), path.join(proofRoot, entry));
373
+ fs.rmdirSync(nested);
374
+ }
375
+ const trustedValidator = fs.existsSync(path.join(KB_DIR, 'coverage-integrity.mjs')) ? path.join(KB_DIR, 'coverage-integrity.mjs')
376
+ : path.join(path.dirname(KB_DIR), 'plugin', 'scripts', 'coverage-integrity.mjs');
377
+ fs.copyFileSync(trustedValidator, path.join(proofRoot, 'coverage-integrity.mjs'));
378
+ const runtimeIdentity = path.join(live, 'RUNTIME-IDENTITY.json');
379
+ if (fs.existsSync(runtimeIdentity)) fs.copyFileSync(runtimeIdentity, path.join(proofRoot, 'RUNTIME-IDENTITY.json'));
380
+ if (releaseTag) recordCorpusTransportIdentity(proofRoot, { releaseTag });
381
+ // Validator/runtime identity are installer-owned bindings added after extraction. Compare the
382
+ // authenticated archive projection while excluding those two local files.
383
+ const archiveIdentity = (root) => {
384
+ const identity = treeIdentity(root);
385
+ const entries = identity.entries.filter((entry) => !['coverage-integrity.mjs', 'RUNTIME-IDENTITY.json'].includes(entry.path));
386
+ return { sha256: createHash('sha256').update(JSON.stringify(entries)).digest('hex'),
387
+ bytes: entries.reduce((sum, entry) => sum + (entry.bytes || 0), 0), fileCount: entries.filter((e) => e.type === 'file').length };
388
+ };
389
+ const stagedIdentity = archiveIdentity(staged);
390
+ const proofIdentity = archiveIdentity(proofRoot);
391
+ if (stagedIdentity.sha256 !== proofIdentity.sha256 || stagedIdentity.bytes !== proofIdentity.bytes || stagedIdentity.fileCount !== proofIdentity.fileCount) {
392
+ throw new Error('staged recovery directory bytes differ from independently extracted signed bundle');
393
+ }
394
+ } finally { fs.rmSync(proofRoot, { recursive: true, force: true }); }
395
+ for (const [dir, label] of [[staged, 'staged release'], [live, 'live KB']]) {
396
+ const stat = fs.lstatSync(dir);
397
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`${label} is not a trusted directory: ${dir}`);
398
+ }
399
+ const validator = validateCoverageDirectory || await loadTrustedCoverageValidator();
400
+ const stagedCoverage = validateReleaseCoverageTree(staged, validator, expectedRuntimeVersion);
401
+ if (!stagedCoverage.valid) throw new Error(`staged ReleaseCoverage failed integrity: ${stagedCoverage.failures.join('; ')}`);
402
+ const liveSource = JSON.parse(fs.readFileSync(path.join(live, 'SOURCE.json'), 'utf8'));
403
+ const liveStores = Array.isArray(liveSource.stores)
404
+ ? liveSource.stores
405
+ : Object.entries(liveSource.stores || {}).map(([kbName, value]) => ({ kbName, ...value }));
406
+ const overlay = capturePrivateOverlayState({ kbDir: live, allStores: liveStores });
407
+ const prepareCandidate = ({ candidateDir, liveDir }) => {
408
+ for (const name of ['coverage-integrity.mjs']) {
409
+ const trusted = fs.existsSync(path.join(KB_DIR, name)) ? path.join(KB_DIR, name)
410
+ : path.join(path.dirname(KB_DIR), 'plugin', 'scripts', name);
411
+ if (!fs.existsSync(trusted)) throw new Error(`trusted staged recovery runtime file is missing: ${name}`);
412
+ const trustedStat = fs.lstatSync(trusted);
413
+ if (!trustedStat.isFile() || trustedStat.isSymbolicLink()) throw new Error(`trusted staged recovery runtime file is not a regular package file: ${name}`);
414
+ fs.copyFileSync(trusted,
415
+ assertNoFollowPath(candidateDir, path.join(candidateDir, name)));
416
+ }
417
+ const runtimeIdentity = path.join(liveDir, 'RUNTIME-IDENTITY.json');
418
+ if (!fs.existsSync(runtimeIdentity)) throw new Error('trusted staged recovery runtime file is missing: RUNTIME-IDENTITY.json');
419
+ const runtime = JSON.parse(fs.readFileSync(runtimeIdentity, 'utf8'));
420
+ if (runtime.brainVersion !== expectedRuntimeVersion) throw new Error('installed runtime identity differs from the approved recovery runtime');
421
+ fs.copyFileSync(assertNoFollowPath(liveDir, runtimeIdentity),
422
+ assertNoFollowPath(candidateDir, path.join(candidateDir, 'RUNTIME-IDENTITY.json')));
423
+ const privateFence = path.join(liveDir, 'PRIVATE-STORES.json');
424
+ if (fs.existsSync(privateFence)) fs.copyFileSync(privateFence,
425
+ assertNoFollowPath(candidateDir, path.join(candidateDir, 'PRIVATE-STORES.json')));
426
+ // S1: ONE APPLY PATH — the same helper main()'s normal apply uses, not a second, duplicated
427
+ // copy-loop. It also adds the collision refusal main() previously lacked.
428
+ restorePrivateFilesIntoCandidate({ candidateDir, sourceDir: liveDir, overlay });
429
+ if (releaseTag) {
430
+ recordCorpusTransportIdentity(candidateDir, { releaseTag });
431
+ // S2: stamped atomically alongside the transport tag, exactly as main()'s prepareCandidate
432
+ // does. This recovery rail's caller does not currently thread a parsed corpus-generation value
433
+ // through (bin/install.mjs's resolveRelease() does not expose the release body it would come
434
+ // from) — passing null here is not a loss of safety: recordCorpusGenerationIdentity's own
435
+ // no-generation branch clears any stale stamp, so the NEXT ordinary --check/--apply reads
436
+ // UNKNOWN (apply allowed, never wrongly REFUSED, never wrongly CURRENT) rather than comparing
437
+ // against a generation this recovered tree does not actually carry.
438
+ recordCorpusGenerationIdentity(candidateDir, { corpusReleaseTag: releaseTag, generation: corpusGeneration });
439
+ }
440
+ const result = validateReleaseCoverageTree(candidateDir, validator, expectedRuntimeVersion);
441
+ if (!result.valid) throw new Error(`candidate public/private convergence failed: ${result.failures.join('; ')}`);
442
+ };
443
+ const validate = ({ dir }) => {
444
+ const result = validateReleaseCoverageTree(dir, validator, expectedRuntimeVersion);
445
+ return result.valid ? { valid: true, failures: [] } : result;
446
+ };
447
+ const lock = acquireUpdateLock({ kbDir: live });
448
+ try {
449
+ const transaction = runStorageTransaction({ liveDir: live, sourceDir: staged, transactionId,
450
+ prepareCandidate, validateCandidate: validate, validateLive: validate });
451
+ return { ...transaction, stagedRelease: { bundleSha256: actualBundleSha256,
452
+ packageIdentity, expectedRuntimeVersion, releaseTag } };
453
+ } finally { releaseUpdateLock(lock); }
454
+ }
455
+
456
+ function validateProfiledReleaseTree(root, profile, overlay) {
457
+ const failures = [];
458
+ try {
459
+ const coverage = JSON.parse(fs.readFileSync(path.join(root, 'COVERAGE.json'), 'utf8'));
460
+ const publicFile = path.join(root, 'PUBLIC-RVF-GENERATIONS.json');
461
+ const publicBytes = fs.readFileSync(publicFile);
462
+ const publicLedger = JSON.parse(publicBytes);
463
+ const runtimeLedger = JSON.parse(fs.readFileSync(path.join(root, 'RVF-GENERATIONS.json'), 'utf8'));
464
+ if (coverage.generationLedger?.file !== 'PUBLIC-RVF-GENERATIONS.json'
465
+ || coverage.generationLedger.sha256 !== createHash('sha256').update(publicBytes).digest('hex')
466
+ || coverage.generationLedger.bytes !== publicBytes.length) failures.push('immutable public ledger differs from ReleaseCoverage');
467
+ const privateNames = new Set(Object.keys(overlay?.sourceStores || {}));
468
+ const expectedPublic = profile === 'ruvector' ? new Set(['ruvector']) : new Set(Object.keys(publicLedger.stores || {}));
469
+ const actualFamilies = new Set(discoverStoreFamilies(root));
470
+ const runtimeNames = Object.keys(runtimeLedger.stores || {}).sort();
471
+ const expectedRuntime = [...expectedPublic, ...privateNames].sort();
472
+ if (JSON.stringify(runtimeNames) !== JSON.stringify(expectedRuntime)) failures.push('profiled runtime ledger store set differs');
473
+ for (const name of expectedPublic) {
474
+ if (JSON.stringify(runtimeLedger.stores?.[name]) !== JSON.stringify(publicLedger.stores?.[name])) {
475
+ failures.push(`profiled runtime public generation differs for ${name}`);
476
+ continue;
477
+ }
478
+ const generation = publicLedger.stores[name];
479
+ const file = path.join(root, String(generation?.file || ''));
480
+ if (!fs.existsSync(file) || fs.statSync(file).size !== generation.bytes || sha256File(file) !== generation.sha256) {
481
+ failures.push(`profiled public RVF differs for ${name}`);
482
+ }
483
+ }
484
+ for (const name of expectedRuntime) if (!actualFamilies.has(name)) failures.push(`profiled store family is missing: ${name}`);
485
+ for (const name of actualFamilies) if (!expectedRuntime.includes(name)) failures.push(`profiled tree has an unselected store family: ${name}`);
486
+ } catch (error) { failures.push(error.message); }
487
+ return { valid: failures.length === 0, failures };
488
+ }
489
+
490
+ function phaseEvidenceFor({ root, terminalVerdict, bundleSha256 = null, transactionReceipts = null,
491
+ overlay = null, storageDelta = null }) {
492
+ const coverage = JSON.parse(fs.readFileSync(path.join(root, 'COVERAGE.json'), 'utf8'));
493
+ const ledgerBytes = fs.readFileSync(path.join(root, 'PUBLIC-RVF-GENERATIONS.json'));
494
+ const currentRows = (coverage.rows || []).filter((row) => row.disposition === 'eligible' && row.status === 'CURRENT');
495
+ const evidence = {
496
+ 'source-enumeration': { sourceObservationSha256: coverage.sourceObservationSha256,
497
+ rows: coverage.totals?.rows, terminal: coverage.enumerationReceipt?.terminal === true },
498
+ ingestion: { eligibleCurrent: currentRows.length, storeCount: coverage.generationLedger?.storeCount },
499
+ 'local-overlay-restoration': { restoredStores: Object.keys(overlay?.sourceStores || {}).length },
500
+ 'generation-ledger-reconciliation': { file: 'PUBLIC-RVF-GENERATIONS.json',
501
+ sha256: createHash('sha256').update(ledgerBytes).digest('hex'), bytes: ledgerBytes.length },
502
+ 'coverage-generation': { releaseCoverageGeneration: coverage.releaseCoverageGeneration,
503
+ coverageSha256: sha256File(path.join(root, 'COVERAGE.json')) },
504
+ 'bundle-assembly': { bundleSha256, version: coverage.releaseIdentity?.version,
505
+ sourceSnapshot: coverage.releaseIdentity?.sourceSnapshot },
506
+ update: { terminalVerdict, transactionReceipts, storageDelta },
507
+ };
508
+ // A consumer validates a published release; it does not rerun its upstream
509
+ // enumeration, ingestion, or assembly. A recent update cannot freshen that evidence.
510
+ return Object.fromEntries(Object.entries(evidence).map(([phase, detail]) => [phase, { ...detail,
511
+ execution: phase === 'update' || (phase === 'local-overlay-restoration' && overlay !== null)
512
+ ? { kind: 'executed', runId: updateLock?.runId || null }
513
+ : phase === 'local-overlay-restoration'
514
+ ? { kind: 'not-executed' }
515
+ : { kind: 'imported-release', sourceSnapshot: coverage.releaseIdentity?.sourceSnapshot || null,
516
+ upstreamFreshness: 'UNKNOWN' },
517
+ }]));
518
+ }
519
+
520
+ // SYMLINK POLICY IS PER-CALLER (issues #130/#131, fixed 2026-08-10).
521
+ //
522
+ // This threw on ANY symlink anywhere in the tree. That is exactly right when validating a governed
523
+ // store payload — a store file that is a symlink is an attack surface, and PR #124 hardened it for
524
+ // good reason. It is exactly WRONG when merely inventorying a backup, because a KB backup contains
525
+ // node_modules, and npm's `.bin` entries are ALWAYS symlinks. So the first `.bin/semver` link made
526
+ // every backup inventory "incomplete", reclaimBackups() fail closed, and the refusal was permanent
527
+ // rather than incidental:
528
+ //
529
+ // KEPT kb.bak-… — inventory is incomplete; refusing destructive reclaim
530
+ // (unreadable inventory tree: symbolic link is not a governed regular file: node_modules/…)
531
+ //
532
+ // Measured consequence on this machine: 63 backups, ~72 GB, every one refused for the same reason,
533
+ // growing by ~1.2 GB per nightly run. The refusal was safe and the scope was wrong — a guard that
534
+ // can never pass is not protecting anything, it is just leaking disk.
535
+ //
536
+ // `strict` (the default) keeps the original behaviour for every governed-payload caller. The
537
+ // inventory walk opts out — but ONLY for symlinks that cannot be a store file; a symlinked `.rvf`
538
+ // still throws, because that is the case the hardening exists for.
539
+ function relativeFiles(dir, prefix = '', { strict = true } = {}) {
540
+ const files = [];
541
+ for (const entry of fs.readdirSync(path.join(dir, prefix), { withFileTypes: true })) {
542
+ const relative = path.join(prefix, entry.name);
543
+ if (entry.isSymbolicLink()) {
544
+ // A symlinked store file is never acceptable, in either mode.
545
+ if (strict || /\.rvf$/i.test(entry.name)) {
546
+ throw new Error(`symbolic link is not a governed regular file: ${relative}`);
547
+ }
548
+ continue; // ordinary tooling symlink (npm .bin, etc.) — not ours to govern, not ours to follow
549
+ }
550
+ if (entry.isDirectory()) files.push(...relativeFiles(dir, relative, { strict }));
551
+ else if (entry.isFile()) files.push(relative);
552
+ }
553
+ return files;
554
+ }
555
+
556
+ /** Snapshot private deployment metadata before a public bundle overwrites shared registry files. */
557
+ export function capturePrivateOverlayState({ kbDir, allStores }) {
558
+ const privateSource = Object.fromEntries((Array.isArray(allStores) ? allStores : [])
559
+ .filter((store) => store?.updateManaged === false && store.kbName)
560
+ .map((store) => [store.kbName, { ...store }]));
561
+ const privateNames = new Set(Object.keys(privateSource));
562
+ if (!privateNames.size) return null;
563
+
564
+ const generations = JSON.parse(fs.readFileSync(path.join(kbDir, 'RVF-GENERATIONS.json'), 'utf8'));
565
+ const aliases = JSON.parse(fs.readFileSync(path.join(kbDir, 'repo-aliases.json'), 'utf8'));
566
+ const privateGenerations = {};
567
+ const privateArtifactFiles = new Set();
568
+ const privateArtifactPrefixes = [];
569
+ for (const name of privateNames) {
570
+ if (!generations.stores?.[name]) throw new Error(`private store ${name} has no RVF generation record`);
571
+ const generation = generations.stores[name];
572
+ if (typeof generation.file !== 'string' || !generation.file.trim()) {
573
+ throw new Error(`private store ${name} has no RVF generation file`);
574
+ }
575
+ const relative = path.normalize(generation.file);
576
+ const resolved = path.resolve(kbDir, relative);
577
+ if (path.isAbsolute(generation.file) || resolved === path.resolve(kbDir)
578
+ || !resolved.startsWith(`${path.resolve(kbDir)}${path.sep}`)) {
579
+ throw new Error(`private store ${name} has unsafe RVF generation file: ${generation.file}`);
580
+ }
581
+ if (!fs.existsSync(resolved)) {
582
+ throw new Error(`private store ${name} RVF generation file is missing: ${generation.file}`);
583
+ }
584
+ const artifactStat = fs.lstatSync(resolved);
585
+ if (artifactStat.isSymbolicLink()) {
586
+ throw new Error(`private store ${name} RVF generation file is a symbolic link: ${generation.file}`);
587
+ }
588
+ if (!artifactStat.isFile()) {
589
+ throw new Error(`private store ${name} RVF generation file is not a regular file: ${generation.file}`);
590
+ }
591
+ const realKbDir = fs.realpathSync(kbDir);
592
+ const realArtifact = fs.realpathSync(resolved);
593
+ if (!realArtifact.startsWith(`${realKbDir}${path.sep}`)) {
594
+ throw new Error(`private store ${name} RVF generation file resolves outside the KB: ${generation.file}`);
595
+ }
596
+ privateGenerations[name] = generation;
597
+ privateArtifactFiles.add(relative);
598
+ const directory = path.dirname(relative);
599
+ const basename = path.basename(relative);
600
+ const stem = basename.replace(/(?:\.big)?\.rvf$/i, '');
601
+ privateArtifactPrefixes.push({ directory, basename, stem });
602
+ }
603
+ const privateAliases = Object.fromEntries(Object.entries(aliases).filter(([name, values]) =>
604
+ privateNames.has(name)
605
+ || (Array.isArray(values) && values.some((value) => privateNames.has(value)))));
606
+ const privateCardNames = new Set([...privateNames, ...Object.keys(privateAliases)]);
607
+ const cardsFile = path.join(kbDir, 'capability-cards.md');
608
+ const cards = fs.existsSync(cardsFile) ? cardSections(fs.readFileSync(cardsFile, 'utf8')) : new Map();
609
+ const privateCards = Object.fromEntries([...cards].filter(([name]) => privateCardNames.has(name)));
610
+ // INVENTORY walk, not a governed-payload walk (policy above, :319-337): the live root carries the
611
+ // installer's own node_modules/.bin/* symlinks, and the first flagged private store (2026-09-12)
612
+ // turned that into "private overlay preflight failed" on a symlink no store owns. A symlinked
613
+ // `.rvf` still throws inside relativeFiles, and :383-386 re-checks every private artifact.
614
+ const privateFiles = Object.fromEntries(relativeFiles(kbDir, '', { strict: false })
615
+ .filter((relative) => privateArtifactFiles.has(relative)
616
+ || [...privateNames].some((name) => {
617
+ const basename = path.basename(relative);
618
+ return basename === name || basename.startsWith(`${name}.`) || basename.startsWith(`${name}-`);
619
+ })
620
+ || privateArtifactPrefixes.some((artifact) => {
621
+ if (path.dirname(relative) !== artifact.directory) return false;
622
+ const basename = path.basename(relative);
623
+ return basename === artifact.basename
624
+ || basename.startsWith(`${artifact.basename}.`)
625
+ || basename.startsWith(`${artifact.stem}.`)
626
+ || basename.startsWith(`${artifact.stem}-`);
627
+ }))
628
+ .map((relative) => {
629
+ const file = path.join(kbDir, relative);
630
+ return [relative, { bytes: fs.statSync(file).size, sha256: sha256File(file) }];
631
+ }));
632
+ for (const relative of privateArtifactFiles) {
633
+ if (!Object.hasOwn(privateFiles, relative)) {
634
+ throw new Error(`private RVF generation file was not captured: ${relative}`);
635
+ }
636
+ }
637
+ return { sourceStores: privateSource, generationStores: privateGenerations, aliases: privateAliases, cards: privateCards, files: privateFiles };
638
+ }
639
+
640
+ /** Restore private metadata after public extraction, refusing collisions before writing anything. */
641
+ export function restorePrivateOverlayState({ kbDir, overlay }) {
642
+ if (!overlay) return { restored: 0 };
643
+ const sourceFile = path.join(kbDir, 'SOURCE.json');
644
+ const generationsFile = path.join(kbDir, 'RVF-GENERATIONS.json');
645
+ const aliasesFile = path.join(kbDir, 'repo-aliases.json');
646
+ const cardsFile = path.join(kbDir, 'capability-cards.md');
647
+ const source = JSON.parse(fs.readFileSync(sourceFile, 'utf8'));
648
+ const generations = JSON.parse(fs.readFileSync(generationsFile, 'utf8'));
649
+ const aliases = JSON.parse(fs.readFileSync(aliasesFile, 'utf8'));
650
+ const mergedSource = mergePrivateEntries(source.stores, overlay.sourceStores, 'SOURCE.json');
651
+ const mergedGenerations = mergePrivateEntries(generations.stores, overlay.generationStores, 'RVF-GENERATIONS.json');
652
+ const mergedAliases = mergePrivateEntries(aliases, overlay.aliases, 'repo-aliases.json');
653
+ for (const [relative, expected] of Object.entries(overlay.files || {})) {
654
+ const file = path.join(kbDir, relative);
655
+ if (!fs.existsSync(file)) throw new Error(`private file missing after update: ${relative}`);
656
+ if (fs.statSync(file).size !== expected.bytes || sha256File(file) !== expected.sha256) {
657
+ throw new Error(`private file changed during public update: ${relative}`);
658
+ }
659
+ }
660
+
661
+ const publicCardsText = fs.existsSync(cardsFile) ? fs.readFileSync(cardsFile, 'utf8') : '';
662
+ const publicCards = cardSections(publicCardsText);
663
+ for (const [name, section] of Object.entries(overlay.cards || {})) {
664
+ if (publicCards.has(name) && publicCards.get(name) !== section) {
665
+ throw new Error(`capability-cards.md collision for private store ${name}`);
666
+ }
667
+ publicCards.set(name, section);
668
+ }
669
+ const preambleEnd = publicCardsText.search(/^## /m);
670
+ const preamble = preambleEnd >= 0 ? publicCardsText.slice(0, preambleEnd).trimEnd() : publicCardsText.trimEnd();
671
+ const mergedCards = `${preamble}${preamble ? '\n\n' : ''}${[...publicCards.values()].join('\n\n')}\n`;
672
+
673
+ atomicJson(sourceFile, { ...source, stores: mergedSource });
674
+ atomicJson(generationsFile, { ...generations, stores: mergedGenerations });
675
+ atomicJson(aliasesFile, mergedAliases);
676
+ fs.writeFileSync(`${cardsFile}.tmp-${process.pid}`, mergedCards);
677
+ fs.renameSync(`${cardsFile}.tmp-${process.pid}`, cardsFile);
678
+ return { restored: Object.keys(overlay.sourceStores).length };
679
+ }
680
+
681
+ /**
682
+ * ONE APPLY PATH (S1): copy the captured private overlay's artifact files from `sourceDir` (the
683
+ * live tree, still untouched at this point) onto `candidateDir` (the sibling tree
684
+ * `runStorageTransaction` builds from the freshly extracted public bundle), then restore the
685
+ * private registry entries with `restorePrivateOverlayState`.
686
+ *
687
+ * This is the ONLY place production code copies private files into a tree that is about to become
688
+ * live — `bin/install.mjs` and `kb/forge-update.mjs`'s `main()` both call this from inside
689
+ * `prepareCandidate`, before `runStorageTransaction` ever renames anything into place. There used to
690
+ * be a second, parallel implementation (`applyPublicBundlePreservingPrivate`) that operated on a
691
+ * full-tree copy-then-restore-from-backup model; it was never wired into `main()` — the real apply
692
+ * path already used `runStorageTransaction`'s rename-based candidate/rollback machinery — so it was
693
+ * exercised only by its own tests. Deleted rather than kept "for coverage": a second apply path that
694
+ * production code never calls is not a safety net, it is a second implementation to keep in sync
695
+ * (and the one place it silently diverged from the real path is the collision check below, which
696
+ * the real path had NOT been enforcing).
697
+ *
698
+ * Collision detection matters here specifically because it did not previously exist on the real
699
+ * path: `candidateDir` already holds the extracted public bundle's files (built by
700
+ * `fs.cpSync(sourceDir=extractDir, candidateDir, ...)` before `prepareCandidate` runs), so copying a
701
+ * private file over a same-named public one would silently discard the public bytes. Refusing BEFORE
702
+ * copying anything is the assertion `applyPublicBundlePreservingPrivate` had and the real path did
703
+ * not; it is preserved here rather than dropped.
704
+ */
705
+ export function restorePrivateFilesIntoCandidate({ candidateDir, sourceDir, overlay }) {
706
+ if (!overlay) return { restored: 0 };
707
+ for (const relative of Object.keys(overlay.files || {})) {
708
+ const target = assertNoFollowPath(candidateDir, path.join(candidateDir, relative));
709
+ if (fs.existsSync(target)) {
710
+ throw new Error(`public bundle collides with private file ${relative}; refusing to copy`);
711
+ }
712
+ const source = assertNoFollowPath(sourceDir, path.join(sourceDir, relative));
713
+ if (!fs.existsSync(source) || !fs.lstatSync(source).isFile()) {
714
+ throw new Error(`private source file is missing or not regular: ${relative}`);
715
+ }
716
+ fs.mkdirSync(path.dirname(target), { recursive: true });
717
+ fs.copyFileSync(source, target);
718
+ }
719
+ return restorePrivateOverlayState({ kbDir: candidateDir, overlay });
720
+ }
721
+
722
+ const manifestUrl = source.canonicalManifestUrl || stores.find((s) => s.canonicalManifestUrl)?.canonicalManifestUrl;
723
+ if (!manifestUrl) {
724
+ die(`self-update not configured for this build — SOURCE.json has no canonicalManifestUrl ` +
725
+ `(forge-build.mjs was run without --canonical-url). Provenance is still in SOURCE.json.`);
726
+ }
727
+
728
+ async function fetchJson(url) {
729
+ let res;
730
+ try { res = await fetch(url, { redirect: 'follow' }); }
731
+ catch (e) { die(`network failure fetching ${url}\n ${e.message} — nothing changed locally.`, 2); }
732
+ if (!res.ok) die(`canonical manifest returned HTTP ${res.status} for ${url} — nothing changed.`, 2);
733
+ try { return await res.json(); } catch (e) { die(`canonical manifest was not valid JSON: ${e.message}`, 2); }
734
+ }
735
+ async function fetchBuffer(url, { failureCode = 2, kind = 'bundle' } = {}) {
736
+ let res;
737
+ try { res = await fetch(url, { redirect: 'follow' }); }
738
+ catch (e) { die(`network failure downloading ${kind} ${url}\n ${e.message} — nothing changed locally.`, failureCode); }
739
+ if (!res.ok) die(`${kind} download returned HTTP ${res.status} for ${url} — nothing changed.`, failureCode);
740
+ return Buffer.from(await res.arrayBuffer());
741
+ }
742
+
743
+ // The canonical manifest can be ONE of three shapes — handle all three:
744
+ // 1. a forge .last-built.json ({ generated, stores:{name:{sha,describe}} })
745
+ // 2. a SOURCE.json-shaped file ({ builtUtc, stores:{name:{builtUtc,sourceCommit,...}} })
746
+ // 3. a GitHub "releases/latest" payload ({ tag_name, published_at, target_commitish })
747
+ // Shape 3 is what this project actually publishes (the brain ships as a GitHub Release, not as
748
+ // committed files), so we detect it by the presence of tag_name and map its fields across.
749
+ function isGithubReleasePayload(canon) {
750
+ return Boolean(canon && typeof canon === 'object' && canon.tag_name);
751
+ }
752
+ function canonicalFor(canon, kbName) {
753
+ if (isGithubReleasePayload(canon)) {
754
+ // The whole Release advances together — every store shares the Release tag + publish time.
755
+ //
756
+ // WHICH IDENTITY DOMAIN the tag belongs to is decided here, once. A `corpus-sha256-<64 hex>`
757
+ // tag is a CONTENT address of a corpus archive; a `vX.Y.Z` tag is the version of a code
758
+ // release. Putting a corpus tag in `releaseTag` makes isBehind() compare a content address
759
+ // against a semver, which can never converge — that is the measured redownload loop
760
+ // (kb/corpus-release-identity.mjs's header records the four-download measurement).
761
+ const corpus = isCorpusReleaseTag(canon.tag_name);
762
+ return {
763
+ builtUtc: canon.published_at || canon.created_at || null,
764
+ // No per-store git sha in a Release payload; use the tag as the version identity instead.
765
+ sourceCommit: null,
766
+ sourceDescribe: canon.tag_name,
767
+ releaseTag: corpus ? null : canon.tag_name,
768
+ corpusReleaseTag: corpus ? canon.tag_name : null,
769
+ };
770
+ }
771
+ const cs = (canon.stores && canon.stores[kbName]) || {};
772
+ return {
773
+ builtUtc: cs.builtUtc || canon.generated || canon.builtUtc || null,
774
+ sourceCommit: cs.sha || cs.sourceCommit || null,
775
+ sourceDescribe: cs.describe || cs.sourceDescribe || null,
776
+ releaseTag: null,
777
+ corpusReleaseTag: null,
778
+ };
779
+ }
780
+ /** The installed tree's own currency identity, read from its top-level SOURCE.json (`source`). */
781
+ export function installedCurrencyIdentity(src) {
782
+ return {
783
+ releaseTag: (src && typeof src.releaseTag === 'string' && src.releaseTag) || null,
784
+ corpusReleaseTag: (src && typeof src.corpusReleaseTag === 'string' && src.corpusReleaseTag) || null,
785
+ corpusGeneration: (src && typeof src.corpusGeneration === 'string' && src.corpusGeneration) || null,
786
+ };
787
+ }
788
+
789
+ /**
790
+ * The CANDIDATE's currency identity, derived from the live manifest/Release payload `main()` already
791
+ * fetched — BEFORE any download. `--check` never downloads, so this is the only data a verdict can
792
+ * ever be computed from pre-download; `--apply` deliberately reuses this exact same decision rather
793
+ * than a second, download-time comparison (the same "decide once, from the live fetch" discipline
794
+ * `resolveBundleUrl`/`verifyLanded` already apply elsewhere in this file).
795
+ */
796
+ export function candidateCurrencyIdentity(canon) {
797
+ if (!isGithubReleasePayload(canon)) {
798
+ // A forge `.last-built.json` or SOURCE.json-shaped manifest (shapes 1/2 — see the comment above
799
+ // `isGithubReleasePayload`). Neither carries a code or corpus release tag, so there is no ordering
800
+ // key to compare by. This project in practice ships shape 3 only.
801
+ return { kind: 'other', tag: null, corpusReleaseTag: null, corpusGeneration: null, corpusGenerationEpoch: null };
802
+ }
803
+ const tag = canon.tag_name;
804
+ const kind = releaseKind(tag);
805
+ // The generation ordering key travels in the release's own body/notes — the same
806
+ // `Corpus generation:` line scripts/corpus-promotion.mjs already treats as the sole author-side
807
+ // ordering key for `releases/latest` promotion (never a locally-observed timestamp). `canon` is
808
+ // already the live, freshly-fetched Release payload from GitHub's API, the same trust boundary this
809
+ // file already extends to `canon.tag_name`/`canon.assets[]` — reusing its `body` field costs no
810
+ // extra network round trip and is available to `--check`, which never downloads the archive itself.
811
+ const parsed = kind === 'corpus' ? parseCorpusGeneration(canon.body) : null;
812
+ return {
813
+ kind,
814
+ tag: tag || null,
815
+ corpusReleaseTag: kind === 'corpus' ? tag : null,
816
+ corpusGeneration: parsed ? parsed.value : null,
817
+ corpusGenerationEpoch: parsed ? parsed.epoch : null,
818
+ };
819
+ }
820
+
821
+ /**
822
+ * ONE CURRENCY VERDICT (S2). Replaces isBehind()'s three ad hoc fallback tiers (releaseTag ->
823
+ * builtUtc -> sourceCommit) with one explicit decision per release channel, made from an ordering key
824
+ * — NEVER a locally-observed timestamp. isBehind()'s builtUtc/sourceCommit tiers compared the
825
+ * candidate's PRE-FETCH manifest timestamp (a Release's publish time, always later than the KB inside
826
+ * it was forged) against the installed copy's own forge time — the exact redownload loop measured in
827
+ * this file's header and in kb/corpus-release-identity.mjs's header. That comparison is deleted
828
+ * outright here, not preserved as a fallback tier.
829
+ *
830
+ * @param {{releaseTag: string|null, corpusReleaseTag: string|null, corpusGeneration: string|null}} installed
831
+ * @param {{kind: 'code'|'corpus'|'other', tag: string|null, corpusReleaseTag: string|null, corpusGeneration: string|null, corpusGenerationEpoch: number|null}} candidate
832
+ * @returns {{verdict: 'CURRENT'|'UPDATE_AVAILABLE'|'UNKNOWN'|'REFUSED', reason: string}}
833
+ *
834
+ * CURRENT the candidate is exactly what is already installed.
835
+ * UPDATE_AVAILABLE the candidate genuinely supersedes what is installed.
836
+ * UNKNOWN no ordering key can be verified in either direction — e.g. today's 4.3.22-era
837
+ * installs, which carry no corpus generation stamp at all. NEVER refused, NEVER
838
+ * reported current: apply is allowed to proceed (main() treats it exactly like
839
+ * UPDATE_AVAILABLE for control flow; only the RECORDED verdict differs).
840
+ * REFUSED the candidate is a corpus generation strictly OLDER than the one installed —
841
+ * rollback protection. main() leaves the live tree untouched and exits 0.
842
+ */
843
+ export function currencyVerdict(installed, candidate) {
844
+ if (candidate.kind === 'code') {
845
+ if (candidate.tag && candidate.tag === installed.releaseTag) {
846
+ return { verdict: 'CURRENT', reason: `code release ${candidate.tag} is already installed` };
847
+ }
848
+ // A code release supersedes whatever is installed, corpus or code — its bundle IS the corpus
849
+ // (recordCorpusTransportIdentity's own rationale). Code tags are owner-sequenced semver, not a
850
+ // content address, so there is no "candidate is older" ambiguity to protect against here.
851
+ return { verdict: 'UPDATE_AVAILABLE',
852
+ reason: `code release ${candidate.tag || '(unknown)'} supersedes ${installed.releaseTag || '(none)'}` };
853
+ }
854
+ if (candidate.kind === 'corpus') {
855
+ if (candidate.tag && candidate.tag === installed.corpusReleaseTag) {
856
+ return { verdict: 'CURRENT', reason: `corpus generation ${candidate.tag} is already installed` };
857
+ }
858
+ if (installed.corpusGeneration == null) {
859
+ return { verdict: 'UNKNOWN',
860
+ reason: 'installed tree carries no verifiable corpus generation stamp; cannot prove direction' };
861
+ }
862
+ const installedEpoch = Date.parse(installed.corpusGeneration);
863
+ if (!Number.isFinite(installedEpoch)) {
864
+ return { verdict: 'UNKNOWN', reason: 'installed corpus generation stamp is unparseable' };
865
+ }
866
+ if (candidate.corpusGeneration == null) {
867
+ // The candidate's own published record carries no readable ordering key — fall back to
868
+ // transport identity. Tag equality was already ruled out above, so a differing tag is still
869
+ // real evidence that something changed.
870
+ return { verdict: 'UPDATE_AVAILABLE',
871
+ reason: `candidate ${candidate.tag} carries no generation identity; falling back to transport tag (differs from installed ${installed.corpusReleaseTag || '(none)'})` };
872
+ }
873
+ if (candidate.corpusGenerationEpoch < installedEpoch) {
874
+ return { verdict: 'REFUSED',
875
+ reason: `candidate corpus generation ${candidate.corpusGeneration} predates installed generation ${installed.corpusGeneration} — refusing to move backward` };
876
+ }
877
+ return { verdict: 'UPDATE_AVAILABLE',
878
+ reason: `corpus generation ${candidate.corpusGeneration} supersedes installed ${installed.corpusGeneration}` };
879
+ }
880
+ // No recognizable release identity at all — never fall back to a locally-observed builtUtc/
881
+ // sourceCommit timestamp (the exact bug this function replaces).
882
+ return { verdict: 'UNKNOWN', reason: 'candidate carries no recognizable release identity (neither a code tag nor a corpus tag)' };
883
+ }
884
+ function short(s) { return s ? String(s).slice(0, 12) : '(none)'; }
885
+ function stamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
886
+ function assertNoFollowPath(root, target) {
887
+ const rootPath = path.resolve(root);
888
+ const targetPath = path.resolve(target);
889
+ const relative = path.relative(rootPath, targetPath);
890
+ if (relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) {
891
+ throw new Error(`path escapes KB root: ${target}`);
892
+ }
893
+ const rootStat = fs.lstatSync(rootPath);
894
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) throw new Error(`KB root is not a real directory: ${root}`);
895
+ let current = rootPath;
896
+ for (const part of relative ? relative.split(path.sep) : []) {
897
+ current = path.join(current, part);
898
+ let stat;
899
+ try { stat = fs.lstatSync(current); }
900
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
901
+ if (stat.isSymbolicLink()) throw new Error(`symlink destination is not allowed: ${path.relative(rootPath, current)}`);
902
+ }
903
+ return targetPath;
904
+ }
905
+
906
+ function copyTree(srcDir, dstDir, root = dstDir, prefix = '') {
907
+ for (const ent of fs.readdirSync(srcDir, { withFileTypes: true })) {
908
+ if (ent.isSymbolicLink()) throw new Error(`source bundle contains a symbolic link: ${path.join(prefix, ent.name)}`);
909
+ const relative = path.join(prefix, ent.name);
910
+ const s = path.join(srcDir, ent.name), d = assertNoFollowPath(root, path.join(dstDir, ent.name));
911
+ if (ent.isDirectory()) { if (!fs.existsSync(d)) fs.mkdirSync(d); copyTree(s, d, root, relative); }
912
+ else { if (!fs.existsSync(path.dirname(d))) fs.mkdirSync(path.dirname(d), { recursive: true }); fs.copyFileSync(s, d); }
913
+ }
914
+ }
915
+
916
+ /** Authoritative store identities in a directory, with recursive `.rvf` fallback for old backups. */
917
+ function storeInventory(dir) {
918
+ const stores = new Map();
919
+ const logical = new Map();
920
+ const declaredFiles = new Set();
921
+ let complete = true;
922
+ let reason = null;
923
+ const generationFile = path.join(dir, 'RVF-GENERATIONS.json');
924
+ const hasGenerationFile = fs.existsSync(generationFile);
925
+ try {
926
+ const generations = JSON.parse(fs.readFileSync(generationFile, 'utf8'));
927
+ for (const [name, generation] of Object.entries(generations.stores || {})) {
928
+ if (typeof generation?.file !== 'string' || !generation.file.trim() || path.isAbsolute(generation.file)) {
929
+ complete = false; reason = `invalid generation path for ${name}`; continue;
930
+ }
931
+ const root = path.resolve(dir);
932
+ const file = path.resolve(root, path.normalize(generation.file));
933
+ if (file === root || !file.startsWith(`${root}${path.sep}`) || !fs.existsSync(file)) {
934
+ complete = false; reason = `missing or escaping generation file for ${name}`; continue;
935
+ }
936
+ const stat = fs.lstatSync(file);
937
+ if (!stat.isFile() || stat.isSymbolicLink()) {
938
+ complete = false; reason = `non-regular generation file for ${name}`; continue;
939
+ }
940
+ const realRoot = fs.realpathSync(root);
941
+ const realFile = fs.realpathSync(file);
942
+ if (!realFile.startsWith(`${realRoot}${path.sep}`)) {
943
+ complete = false; reason = `generation file escapes root for ${name}`; continue;
944
+ }
945
+ // Key by the governed artifact path, not by whether this particular generation metadata
946
+ // happened to declare it. Older backups can contain a valid local RVF as an undeclared
947
+ // fallback while the live KB declares the same bytes under a logical store name. Treating
948
+ // those as `file:<path>` versus `store:<name>` made one physical artifact look missing and
949
+ // permanently retained every full-KB rollback copy.
950
+ stores.set(path.normalize(generation.file), generation.file);
951
+ logical.set(name, path.normalize(generation.file));
952
+ declaredFiles.add(generation.file);
953
+ }
954
+ } catch (error) {
955
+ if (hasGenerationFile) { complete = false; reason = `unreadable RVF-GENERATIONS.json: ${error.message}`; }
956
+ }
957
+ try {
958
+ // Inventory only: tolerate ordinary tooling symlinks (npm .bin). A symlinked .rvf still throws.
959
+ const files = relativeFiles(dir, '', { strict: false });
960
+ for (const relative of files.filter((name) => name.endsWith('.rvf'))) {
961
+ if (!declaredFiles.has(relative)) {
962
+ stores.set(path.normalize(relative), relative);
963
+ }
964
+ }
965
+ const legacyMetadata = new Set([
966
+ 'SOURCE.json', 'repo-aliases.json', 'capability-cards.md', 'package.json', 'package-lock.json',
967
+ 'forge-update.mjs', 'zip-extract.mjs', 'brain-profile.mjs', 'refresh-run.mjs',
968
+ 'update-storage-transaction.mjs', 'lifecycle-evidence-retention.mjs', 'manifest.json',
969
+ 'coverage-integrity.mjs', 'COVERAGE.json', 'CORPUS-COVERAGE.json', 'COVERAGE.md',
970
+ '.refresh-snapshot.json',
971
+ // ADR-086 step 16: the updater's own module graph grew one file, and the installer now writes
972
+ // one record beside the validator it already wrote. Both are metadata, not user stores —
973
+ // omitting them here would make a legacy KB read as "unclassified non-RVF files" and refuse.
974
+ 'corpus-release-identity.mjs', 'RUNTIME-IDENTITY.json',
975
+ ]);
976
+ if (!hasGenerationFile && files.some((name) => !name.endsWith('.rvf') && !legacyMetadata.has(path.basename(name)))) {
977
+ complete = false; reason = 'legacy inventory contains unclassified non-RVF files';
978
+ }
979
+ } catch (error) {
980
+ complete = false; reason = `unreadable inventory tree: ${error.message}`;
981
+ }
982
+ return { stores, logical, complete, reason };
983
+ }
984
+
985
+ /** Recursive byte size, for honestly reporting how much was actually reclaimed. */
986
+ function dirSize(dir) {
987
+ try { if (fs.lstatSync(dir).isSymbolicLink()) return fs.lstatSync(dir).size; }
988
+ catch { return 0; }
989
+ let total = 0;
990
+ const walk = (d) => {
991
+ let entries; try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
992
+ for (const e of entries) {
993
+ const p = path.join(d, e.name);
994
+ if (e.isDirectory()) walk(p); else { try { total += fs.lstatSync(p).size; } catch { /* vanished mid-walk */ } }
995
+ }
996
+ };
997
+ walk(dir);
998
+ return total;
999
+ }
1000
+
1001
+ // A backup-looking name and matching RVF paths are not evidence that its other
1002
+ // bytes are disposable. Legacy backups have no authenticated complete ownership
1003
+ // receipt: reclaim only a tree whose every entry survives identically in live.
1004
+ function assertRedundantBackup(backup, live) {
1005
+ const compare = (prior, current) => {
1006
+ const a = fs.lstatSync(prior);
1007
+ const b = fs.lstatSync(current);
1008
+ if (a.isSymbolicLink() || b.isSymbolicLink()) {
1009
+ // Compare links themselves, never dereference them. Store symlinks are
1010
+ // already rejected by storeInventory; identical tooling links are safe.
1011
+ if (!a.isSymbolicLink() || !b.isSymbolicLink() || fs.readlinkSync(prior) !== fs.readlinkSync(current)) {
1012
+ throw new Error(`unclassified or different symbolic link: ${prior}`);
1013
+ }
1014
+ } else if (a.isDirectory() && b.isDirectory()) {
1015
+ for (const name of fs.readdirSync(prior)) compare(path.join(prior, name), path.join(current, name));
1016
+ } else if (a.isFile() && b.isFile()) {
1017
+ if (a.size !== b.size || sha256File(prior) !== sha256File(current)) {
1018
+ throw new Error(`different bytes: ${prior}`);
1019
+ }
1020
+ } else throw new Error(`unclassified or different entry type: ${prior}`);
1021
+ };
1022
+ compare(backup, live);
1023
+ }
1024
+
1025
+ function rollbackRetentionPolicy(kbDir, env = process.env) {
1026
+ const liveBytes = dirSize(kbDir);
1027
+ const configuredSnapshots = Number(env.RUVNET_MAX_ROLLBACK_SNAPSHOTS || 1);
1028
+ const configuredBytes = Number(env.RUVNET_MAX_ROLLBACK_BYTES || liveBytes);
1029
+ if (!Number.isSafeInteger(configuredSnapshots) || configuredSnapshots < 0
1030
+ || !Number.isSafeInteger(configuredBytes) || configuredBytes < 0) {
1031
+ throw new Error('rollback retention limits must be non-negative safe integers');
1032
+ }
1033
+ return { maxSnapshots: configuredSnapshots, maxBytes: configuredBytes, requiredSnapshotBytes: liveBytes };
1034
+ }
1035
+
1036
+ function snapshotInventoryDigest(dir) {
1037
+ const inventory = storeInventory(dir);
1038
+ if (!inventory.complete) throw new Error(`snapshot inventory is incomplete (${inventory.reason || 'unknown'})`);
1039
+ const rows = [...inventory.stores].sort(([left], [right]) => left.localeCompare(right)).map(([identity, file]) => {
1040
+ const absolute = path.join(dir, file);
1041
+ return { identity, file, bytes: fs.statSync(absolute).size, sha256: sha256File(absolute) };
1042
+ });
1043
+ return createHash('sha256').update(JSON.stringify(rows)).digest('hex');
1044
+ }
1045
+
1046
+ function writeSnapshotReceipt(backupPath, { state, reason = null, recoveryCommand = null }) {
1047
+ const file = path.join(backupPath, '.refresh-snapshot.json');
1048
+ const receipt = {
1049
+ schemaVersion: 1,
1050
+ kind: 'ruvnet-brain-rollback-snapshot',
1051
+ snapshot: path.basename(backupPath),
1052
+ bytes: dirSize(backupPath),
1053
+ inventorySha256: snapshotInventoryDigest(backupPath),
1054
+ state,
1055
+ reason,
1056
+ recoveryCommand: recoveryCommand || `restore ${backupPath} to ${KB_DIR}`,
1057
+ updatedAt: new Date().toISOString(),
1058
+ };
1059
+ atomicJson(file, receipt);
1060
+ return receipt;
1061
+ }
1062
+
1063
+ /**
1064
+ * Release rollback copies after the new KB has verified (issue #35, Dr. Mark Allen).
1065
+ *
1066
+ * Exported and pure-ish because it DELETES MULTI-GIGABYTE DIRECTORIES — a bug here destroys user
1067
+ * data, so it is tested directly rather than exercised only through a full update run.
1068
+ *
1069
+ * Refuses to delete any backup holding a `.rvf` store the live KB does not have. That is the
1070
+ * private/local-store case: the public bundle does not ship those, the update replaces the directory,
1071
+ * and forge-guard still passes because it verifies the store it was asked about — not what went
1072
+ * missing. In that situation the backup is the only surviving copy, so it is kept and reported.
1073
+ *
1074
+ * @returns {{removed: string[], kept: [string, string][], freed: number}}
1075
+ */
1076
+ /**
1077
+ * WHY a guard run failed. forge-guard prints its `[FAIL] ...` lines to STDOUT, and execFileSync puts only
1078
+ * STDERR in error.message, so a refused store used to read "Command failed: node .../forge-guard.mjs --name X"
1079
+ * with the cause dropped (measured 2026-09-30: the customer canary refused a generation and its log did not
1080
+ * say why). Keep the command line, then append the guard's own FAIL lines.
1081
+ */
1082
+ export function describeGuardFailure(error) {
1083
+ const text = (value) => (value == null ? '' : Buffer.isBuffer(value) ? value.toString('utf8') : String(value));
1084
+ const fails = `${text(error?.stdout)}\n${text(error?.stderr)}`.split('\n')
1085
+ .map((line) => line.trim()).filter((line) => /\[FAIL\]|Error:/.test(line));
1086
+ // Keep the WHOLE message: execFileSync appends the child's stderr on the lines after the command line.
1087
+ const message = String(error?.message || error);
1088
+ const extra = fails.filter((line) => !message.includes(line));
1089
+ const cause = extra.length ? ` -- ${extra.join(' | ').slice(0, 800)}` : '';
1090
+ return `${message.slice(0, 1600)}${cause}`;
1091
+ }
1092
+
1093
+ export function reclaimBackups({
1094
+ kbDir,
1095
+ backupsMade = [],
1096
+ env = process.env,
1097
+ intentionallyRemovedStores = [],
1098
+ dryRun = false,
1099
+ }) {
1100
+ const parent = path.dirname(kbDir);
1101
+ // Older updater and recovery paths used three different names for the same full-KB rollback
1102
+ // copy. Sweeping only `kb.bak-*` left those copies outside retention, which is how issue #235
1103
+ // accumulated 74 directories / 129 GiB. Keep the allowlist narrow: these are exact historical
1104
+ // names owned by this updater, and unrelated siblings must remain untouched.
1105
+ //
1106
+ // `.install-preserved-` (bin/install.mjs) is the installer's copy of the whole prior generation.
1107
+ // It was "not eligible for automatic cleanup" by name alone, so it outlived every proof that could
1108
+ // have released it — measured 2026-09-11: a 1.2 GB brain held three times on one machine. It is a
1109
+ // candidate under EXACTLY the same redundancy proof as every other copy: never deleted unless
1110
+ // every byte survives in the live brain.
1111
+ const base = path.basename(kbDir);
1112
+ const prefixes = [
1113
+ `${base}.bak-`,
1114
+ `${base}.pre-reset-backup-`,
1115
+ `${base}.agent-harness-generator-backup-`,
1116
+ `${base}-pre-gap-rebuild-backup-`,
1117
+ `${base}.install-preserved-`,
1118
+ ];
1119
+ const prefixFor = (entry) => prefixes.find((prefix) => entry.startsWith(prefix)) || null;
1120
+ let stranded = [];
1121
+ try { stranded = fs.readdirSync(parent).filter((n) => prefixFor(n)).map((n) => path.join(parent, n)); }
1122
+ catch { /* unreadable parent — nothing to sweep */ }
1123
+
1124
+ const all = [...new Set([...backupsMade, ...stranded])];
1125
+ const removed = []; const wouldRemove = []; const kept = []; let freed = 0;
1126
+ const safePreserved = new Map();
1127
+ const retentionPolicy = rollbackRetentionPolicy(kbDir, env);
1128
+ const liveInventory = storeInventory(kbDir);
1129
+
1130
+ // A store the live release's own COVERAGE.json marks ineligible is absent from live BY POLICY
1131
+ // (excluded-no-corpus, fork, archived…), not lost by an update; its presence in a backup must not
1132
+ // pin that backup forever. Only rows with a non-eligible disposition qualify — an eligible row that
1133
+ // merely has no artifact (MISSING) is not a decision to drop the store.
1134
+ const readJsonQuietly = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
1135
+ const coverageRows = readJsonQuietly(path.join(kbDir, 'COVERAGE.json'))?.rows;
1136
+ const policyExcluded = (Array.isArray(coverageRows) ? coverageRows : [])
1137
+ .filter((row) => row && row.kind === 'repository' && row.disposition && row.disposition !== 'eligible')
1138
+ .map((row) => String(row.artifact?.store || row.name || '').toLowerCase()).filter(Boolean);
1139
+ // PRIVATE-fenced stores (PRIVATE-STORES.json, read from live AND from the backup itself, since a
1140
+ // backup knows what was private when it was made) are never disposable: a backup holding one the
1141
+ // live brain lacks is the only copy outside the fence. It is pinned and reported by name, and no
1142
+ // caller can authorize it away through `intentionallyRemovedStores`.
1143
+ const fencedNames = (dirs) => new Set(dirs.flatMap((dir) => {
1144
+ const list = readJsonQuietly(path.join(dir, 'PRIVATE-STORES.json'))?.privateStores;
1145
+ return Array.isArray(list) ? list.map((name) => String(name).toLowerCase()) : [];
1146
+ }));
1147
+ const storeStem = (file) => path.basename(String(file)).replace(/(?:\.big)?\.rvf$/i, '').toLowerCase();
1148
+ // Retaining a copy is safe for the NEXT update only when every entry is measured: regular files,
1149
+ // plus symlinks that cannot be a store file AND stay inside the copy (npm's `.bin` links — the
1150
+ // installed brain always carries `node_modules/.bin/semver -> ../semver/bin/semver.js`). A link
1151
+ // that escapes the tree is not measured — its target is what a receipt would silently be counting
1152
+ // — and stays a blocker, exactly as before. A symlinked `.rvf` already fails the inventory above.
1153
+ const pinnedPrivate = new Set();
1154
+ const markMeasured = (b) => {
1155
+ try {
1156
+ const identity = treeIdentity(b);
1157
+ const root = path.resolve(b);
1158
+ const measured = identity.entries.every((entry) => {
1159
+ if (entry.type === 'file') return true;
1160
+ if (entry.type !== 'symlink' || /\.rvf$/i.test(entry.path) || path.isAbsolute(entry.target)) return false;
1161
+ const relative = entry.path.split('/').join(path.sep);
1162
+ return path.resolve(root, path.dirname(relative), entry.target).startsWith(`${root}${path.sep}`);
1163
+ });
1164
+ if (measured) safePreserved.set(b, identity.bytes);
1165
+ } catch { /* retained, but not safe to proceed past recovery preflight */ }
1166
+ };
1167
+
1168
+ for (const b of all) {
1169
+ if (!fs.existsSync(b)) continue;
1170
+ if (env.RUVNET_KEEP_BACKUP === '1') { kept.push([b, 'RUVNET_KEEP_BACKUP=1 is set']); continue; }
1171
+ try {
1172
+ if (path.dirname(path.resolve(b)) !== path.resolve(parent) || !prefixFor(path.basename(b))) {
1173
+ throw new Error('target is not an exact backup sibling');
1174
+ }
1175
+ for (const dir of [parent, kbDir, b]) {
1176
+ const stat = fs.lstatSync(dir);
1177
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`not a real directory: ${dir}`);
1178
+ }
1179
+ } catch (error) { kept.push([b, `unsafe reclaim target: ${error.message}`]); continue; }
1180
+ const backupInventory = storeInventory(b);
1181
+ if (!liveInventory.complete || !backupInventory.complete) {
1182
+ kept.push([b, `inventory is incomplete; refusing destructive reclaim (${backupInventory.reason || liveInventory.reason || 'unknown'})`]);
1183
+ continue;
1184
+ }
1185
+ const privateNames = fencedNames([kbDir, b]);
1186
+ const removedStores = [...new Set([...intentionallyRemovedStores, ...policyExcluded])]
1187
+ .filter((store) => !privateNames.has(String(store).toLowerCase()));
1188
+ const allowedMissing = new Set(removedStores.flatMap((store) => [`${store}.rvf`, `${store}.big.rvf`])
1189
+ .map((file) => path.normalize(file)));
1190
+ for (const store of removedStores) {
1191
+ const governedPath = backupInventory.logical.get(store);
1192
+ if (governedPath) allowedMissing.add(governedPath);
1193
+ }
1194
+ const lost = [...backupInventory.stores].filter(([identity]) => !liveInventory.stores.has(identity) && !allowedMissing.has(identity));
1195
+ const identityNames = new Map();
1196
+ for (const [name, identity] of backupInventory.logical) {
1197
+ identityNames.set(identity, [...(identityNames.get(identity) || []), String(name).toLowerCase()]);
1198
+ }
1199
+ const isFenced = ([identity, file]) => (identityNames.get(identity) || []).some((name) => privateNames.has(name))
1200
+ || privateNames.has(storeStem(file));
1201
+ const lostOther = lost.filter((entry) => !isFenced(entry));
1202
+ const lostPrivate = lost.filter(isFenced);
1203
+ if (lostOther.length) {
1204
+ const labels = lostOther.map(([, file]) => file);
1205
+ const privateNote = lostPrivate.length ? `; also pins PRIVATE: ${lostPrivate.map(([, file]) => file).join(', ')}` : '';
1206
+ kept.push([b, `it holds ${lostOther.length} store(s) the new copy does NOT have: ${labels.slice(0, 3).join(', ')}${lostOther.length > 3 ? '…' : ''}${privateNote}`]);
1207
+ continue;
1208
+ }
1209
+ if (lostPrivate.length) {
1210
+ const labels = lostPrivate.map(([, file]) => file);
1211
+ kept.push([b, `PRIVATE store(s) pinned — the only copy outside the fence: ${labels.join(', ')}; retained, never reclaimed automatically`]);
1212
+ pinnedPrivate.add(b);
1213
+ markMeasured(b);
1214
+ continue;
1215
+ }
1216
+ try { assertRedundantBackup(b, kbDir); }
1217
+ catch (error) {
1218
+ kept.push([b, `PRESERVED_UNCLASSIFIED: complete byte redundancy is not proven; ${error.message}`]);
1219
+ // Preservation does not itself require blocking an isolated transaction. Only a measured
1220
+ // inventory establishes safe retention; missing stores, unsafe roots and unreadable bytes
1221
+ // remain blockers.
1222
+ markMeasured(b);
1223
+ continue;
1224
+ }
1225
+ const size = dirSize(b);
1226
+ if (dryRun) { wouldRemove.push(b); freed += size; continue; }
1227
+ try { fs.rmSync(b, { recursive: true, force: true }); removed.push(b); freed += size; }
1228
+ catch (e) { kept.push([b, `could not remove: ${e.message}`]); }
1229
+ }
1230
+ const retained = all.filter((backup) => fs.existsSync(backup) && !wouldRemove.includes(backup)).map((backup) => {
1231
+ let inventorySha256 = null;
1232
+ let inventoryError = null;
1233
+ try {
1234
+ if (fs.lstatSync(backup).isSymbolicLink()) throw new Error('backup root is a symbolic link');
1235
+ inventorySha256 = snapshotInventoryDigest(backup);
1236
+ }
1237
+ catch (error) { inventoryError = error.message; }
1238
+ return { path: backup, bytes: safePreserved.get(backup) ?? dirSize(backup), inventorySha256, inventoryError,
1239
+ safeToRetainDuringUpdate: safePreserved.has(backup), automaticCleanupEligible: false,
1240
+ retention: pinnedPrivate.has(backup) ? 'private-pinned' : safePreserved.has(backup) ? 'unclassified' : 'unmeasured' };
1241
+ });
1242
+ const retainedBytes = retained.reduce((sum, snapshot) => sum + snapshot.bytes, 0);
1243
+ const retention = { ...retentionPolicy, observedSnapshots: retained.length, observedBytes: retainedBytes,
1244
+ withinBudget: retained.length <= retentionPolicy.maxSnapshots && retainedBytes <= retentionPolicy.maxBytes };
1245
+ // Two facts, kept apart. `blockingRetained` is what an update must not proceed past: a copy that is
1246
+ // unmeasured or holds a store nothing accounts for — and, over budget, any copy retained for no
1247
+ // stated reason (an unclassified one), which is the existing contract (data-safety tests) and stays.
1248
+ // The ONE exemption is a copy pinned for an accounted reason — a fenced PRIVATE store the live
1249
+ // brain lacks: it is user data, measured, and named; exceeding the budget with it is reported as
1250
+ // `overBudget`, not treated as unresolved rollback state, because this update adds no persistent
1251
+ // copy of its own.
1252
+ const blockingRetained = retained.filter((entry) => !entry.safeToRetainDuringUpdate
1253
+ || (!retention.withinBudget && entry.retention !== 'private-pinned'));
1254
+ const overBudget = retention.withinBudget ? null : { snapshots: retained.length, maxSnapshots: retentionPolicy.maxSnapshots,
1255
+ bytes: retainedBytes, maxBytes: retentionPolicy.maxBytes };
1256
+ return { removed, wouldRemove, dryRun, kept, freed, retained, blockingRetained, overBudget,
1257
+ retentionPolicy: retention, withinBudget: retention.withinBudget,
1258
+ updateMayProceed: retention.withinBudget && retained.every((entry) => entry.safeToRetainDuringUpdate) };
1259
+ }
1260
+
1261
+ /**
1262
+ * Decide which URL to actually download the replacement bundle from (issue #35 item 1, Dr. Mark
1263
+ * Allen / @mamd69).
1264
+ *
1265
+ * The OLD code (line 218 before this fix) always used `local.canonicalBundleUrl` — the URL
1266
+ * literally written into the copy of SOURCE.json that is BEING REPLACED. That value can only ever
1267
+ * point BACKWARD: it was correct on the day this copy was forged, and every day after is a day it
1268
+ * could go stale. Mark's machine re-downloaded the same June v0.5.0-dev asset for three weeks
1269
+ * because that pinned URL never moved even though newer releases existed on GitHub the whole time.
1270
+ *
1271
+ * This resolves the URL from `canon` — the live "latest release" (or manifest) payload `main()`
1272
+ * already fetched fresh, moments ago, over the network — instead of the stale local copy:
1273
+ * - Shape 3 (a GitHub `releases/latest` payload — what this project actually publishes): the
1274
+ * release carries real `assets[]` with `browser_download_url`s that GitHub resolves NOW, not
1275
+ * whatever was true when this local copy was built. Prefer the asset whose name matches the
1276
+ * pinned URL's basename; this project in practice ships ONE combined zip per release (not one
1277
+ * per KB store, despite forge-build.mjs's per-store naming convention — the two drifted apart),
1278
+ * so if there's exactly one `.zip` asset and no name match, that unambiguous single zip IS it.
1279
+ * - Shape 1/2 (a forge `.last-built.json` or SOURCE.json-shaped manifest): these can carry the
1280
+ * same `canonicalBundleUrl` field per store, but THIS copy was just fetched fresh over the
1281
+ * network, so it reflects the manifest's CURRENT contents — still a live resolution, not a
1282
+ * pinned local guess.
1283
+ * - Only when neither live source resolves an asset does this fall back to the URL pinned in the
1284
+ * local SOURCE.json — and it says so. Falling back to that value SILENTLY is issue #35 item 3
1285
+ * (the "known-good" bundle applied with zero warning); callers MUST surface `warning` when set.
1286
+ *
1287
+ * @returns {{ url: string|null, origin: 'latest-release-asset'|'live-manifest'|'pinned-fallback'|'none', assetName: string|null, digest: string|null, warning: string|null }}
1288
+ */
1289
+ export function resolveBundleUrl({ canon, local, source }) {
1290
+ const pinned = (local && local.canonicalBundleUrl) || (source && source.canonicalBundleUrl) || null;
1291
+
1292
+ if (canon && Array.isArray(canon.assets) && canon.assets.length) {
1293
+ const wantName = pinned ? path.basename(pinned) : null;
1294
+ let asset = wantName ? canon.assets.find((a) => a && a.name === wantName) : null;
1295
+ if (!asset) {
1296
+ const zips = canon.assets.filter((a) => a && typeof a.name === 'string' && a.name.endsWith('.zip'));
1297
+ if (zips.length === 1) asset = zips[0];
1298
+ }
1299
+ if (asset && (asset.browser_download_url || asset.url)) {
1300
+ return {
1301
+ url: asset.browser_download_url || asset.url,
1302
+ origin: 'latest-release-asset',
1303
+ assetName: asset.name,
1304
+ digest: asset.digest || null,
1305
+ warning: null,
1306
+ };
1307
+ }
1308
+ }
1309
+
1310
+ if (canon && canon.stores && typeof canon.stores === 'object' && !Array.isArray(canon.stores) && local) {
1311
+ const cs = canon.stores[local.kbName];
1312
+ if (cs && cs.canonicalBundleUrl) {
1313
+ return { url: cs.canonicalBundleUrl, origin: 'live-manifest', assetName: path.basename(cs.canonicalBundleUrl), digest: null, warning: null };
1314
+ }
1315
+ }
1316
+
1317
+ if (pinned) {
1318
+ const staleness = local
1319
+ ? `this copy's own record (built ${local.builtUtc || '?'}${local.sourceDescribe ? `, ${local.sourceDescribe}` : local.sourceCommit ? `, ${short(local.sourceCommit)}` : ''})`
1320
+ : `this copy's own record`;
1321
+ return {
1322
+ url: pinned,
1323
+ origin: 'pinned-fallback',
1324
+ assetName: path.basename(pinned),
1325
+ digest: null,
1326
+ warning: `could not resolve a bundle asset from the LIVE manifest ` +
1327
+ `(${canon && canon.tag_name ? `release ${canon.tag_name} has no matching/unambiguous .zip asset` : 'the manifest is not a GitHub Release payload and carries no live canonicalBundleUrl'}); ` +
1328
+ `falling back to the URL PINNED inside ${staleness}: ${pinned} — this can only point BACKWARD (issue #35) and may be stale.`,
1329
+ };
1330
+ }
1331
+
1332
+ return { url: null, origin: 'none', assetName: null, digest: null, warning: null };
1333
+ }
1334
+
1335
+ /**
1336
+ * The identity of a WHOLE bundle, as recorded at the top level of its SOURCE.json.
1337
+ *
1338
+ * This is what advances when a new bundle is published, regardless of which individual stores were
1339
+ * re-forged into it — which is precisely why the "did anything land" question belongs here and not
1340
+ * on a single store (issue #108). Returns null when a SOURCE.json carries no such identity at all
1341
+ * (bundles predating `releaseTag`/`brainVersion`, or a plain forge manifest), so callers can tell
1342
+ * "identical" apart from "no signal to compare".
1343
+ */
1344
+ export function bundleIdentity(src) {
1345
+ if (!src || typeof src !== 'object') return null;
1346
+ // `corpusReleaseTag` is part of bundle identity for the same reason the other three are: it is the
1347
+ // one field that advances when a corpus-only release lands. Without it, two corpus generations
1348
+ // that happened to share a builtUtc would read as "nothing landed" on a genuinely new corpus.
1349
+ const parts = [src.releaseTag, src.brainVersion, src.builtUtc, src.corpusReleaseTag].map((v) => (v == null ? '' : String(v)));
1350
+ return parts.some(Boolean) ? parts.join('|') : null;
1351
+ }
1352
+
1353
+ /**
1354
+ * Confirm the download+extraction actually changed what is on disk (issue #35 item 2, Dr. Mark
1355
+ * Allen / @mamd69).
1356
+ *
1357
+ * The OLD code's final "DONE" message (line 296 before this fix) was built from `canon.tag_name` —
1358
+ * a lookup made BEFORE anything was downloaded — regardless of what the download actually
1359
+ * contained. Mark's run printed "KB updated to the canonical build (v3.4.21-dev)" while his
1360
+ * SOURCE.json on disk still read v0.5.0-dev, because nothing ever re-read it afterward.
1361
+ *
1362
+ * This deliberately does NOT reuse `isBehind()` for the pass/fail decision: `isBehind()` compares
1363
+ * against `canon.builtUtc`, which for a GitHub Release is the RELEASE's publish timestamp — always
1364
+ * a few minutes AFTER the KB inside it was actually forged. Confirmed LIVE against this repo's own
1365
+ * kb/SOURCE.json (2026-07-20): running `--check` against a store forged 2 minutes before its own
1366
+ * release was published already reads BEHIND. Reusing that comparison here would make EVERY
1367
+ * successful update fail this guard too — crying wolf on success is as dishonest as silence on
1368
+ * failure. Instead this checks something isBehind() cannot: does the on-disk identity now differ
1369
+ * from what it was immediately BEFORE this update ran? `builtUtc` is regenerated at every forge
1370
+ * build, so a genuine new build always changes it — an unchanged fingerprint after a "successful"
1371
+ * download IS the bug (identical bytes re-fetched, exactly Mark's report). When the resolved asset
1372
+ * carried a real digest, this also verifies the downloaded bytes against it — the one place a
1373
+ * directly comparable "resolved vs. landed" fact actually exists in a GitHub Release payload.
1374
+ *
1375
+ * THE QUESTION IS ASKED OF THE BUNDLE, NOT OF ONE STORE (issue #108). The first version compared a
1376
+ * PER-STORE fingerprint and treated equality as fatal — but stores are forged INDEPENDENTLY, and a
1377
+ * store whose upstream repo did not move is re-shipped byte-identical inside a genuinely new
1378
+ * bundle. On the reporter's copy 8 of 15 stores shared one stamp, so the first unchanged store in
1379
+ * iteration order aborted the entire run: nightly updates "failed" for three weeks while actually
1380
+ * succeeding, and the abort skipped the rollback release, stranding ~1.6 GB a night. Whitelisting
1381
+ * the store would not have helped — the next unchanged one simply takes its place.
1382
+ *
1383
+ * So the fatal question is the one issue #35 actually asked: did ANYTHING land? That is bundle
1384
+ * identity (releaseTag / brainVersion / top-level builtUtc), which advances whenever a new bundle
1385
+ * is published. Per-store equality is now what it always was in reality — ordinary, and reported
1386
+ * as `storeUnchanged` rather than raised as a failure. A bundle whose identity did NOT move AND
1387
+ * whose store did not move either is the real no-op, and is still refused (issue #106).
1388
+ *
1389
+ * `kind` says what a caller may do about a failure: 'noop' means the KB in place is intact and the
1390
+ * rollback copy is redundant; 'damaged' means the copy in place is suspect and the rollback must
1391
+ * be kept.
1392
+ *
1393
+ * @returns {{ok: boolean, reason: string|null, landed: object|null, kind: 'noop'|'damaged'|null,
1394
+ * storeUnchanged: boolean, bundleChanged: boolean|null}}
1395
+ */
1396
+ export function verifyLanded({ kbDir, kbName, before, beforeBundle = null, expectedDigest = null, downloadedBuffer = null }) {
1397
+ const damaged = (reason, landed = null) => ({ ok: false, kind: 'damaged', reason, landed, storeUnchanged: false, bundleChanged: null });
1398
+ const p = path.join(kbDir, 'SOURCE.json');
1399
+ if (!fs.existsSync(p)) {
1400
+ return damaged(`no SOURCE.json found at ${p} after extraction — cannot confirm what actually landed`);
1401
+ }
1402
+ let landedSource;
1403
+ try { landedSource = JSON.parse(fs.readFileSync(p, 'utf8')); }
1404
+ catch (e) { return damaged(`SOURCE.json on disk after extraction is unreadable/corrupt: ${e.message}`); }
1405
+
1406
+ const list = Array.isArray(landedSource.stores)
1407
+ ? landedSource.stores
1408
+ : (landedSource.stores && typeof landedSource.stores === 'object')
1409
+ ? Object.entries(landedSource.stores).map(([n, v]) => ({ kbName: n, ...v }))
1410
+ : [landedSource];
1411
+ // The single-item, no-kbName-field fallback exists ONLY for the legacy flat schema (a SOURCE.json
1412
+ // predating the multi-store `stores` object — see the identical pattern at the top of this file,
1413
+ // lines 46-50). It must NOT swallow a genuine name mismatch: if the one store present names
1414
+ // itself something else, that is a real "wrong store landed" error, not a format quirk.
1415
+ // THE UPGRADE DIRECTION MATTERS TOO. The lookup above assumes the CALLER knows its store name —
1416
+ // but a legacy flat SOURCE.json has no `stores` object at all, so `stores = [source]` (lines 46-50)
1417
+ // yields an entry whose kbName is undefined, and `kbName` arrives here as undefined. Landing a
1418
+ // modern multi-store bundle over it then matched neither branch, and main() turned that into
1419
+ // "UPDATE MISMATCH — REFUSING to report success" on an update that had genuinely worked.
1420
+ //
1421
+ // That is the worst possible false failure: it permanently blocks self-update for people still on
1422
+ // an OLD bundle — precisely the stale installs this whole issue exists to rescue, and precisely the
1423
+ // users reporting "I'm still on 0.5". A guard that bricks the upgrade path is worse than the bug.
1424
+ const legacyCaller = kbName == null || kbName === 'undefined';
1425
+ const landed = list.find((s) => s.kbName === kbName)
1426
+ || (list.length === 1 && list[0].kbName == null ? { kbName, ...list[0] } : null)
1427
+ // Legacy caller upgrading into the modern schema: any single landed store is unambiguous.
1428
+ || (legacyCaller && list.length === 1 ? { kbName: list[0].kbName, ...list[0] } : null);
1429
+ if (!landed) {
1430
+ return damaged(`SOURCE.json on disk after extraction has no entry for store "${kbName}"`);
1431
+ }
1432
+
1433
+ // Bytes that do not match what the release declared are a HARD failure whatever the identities
1434
+ // say, and the copy now in place is suspect — so this is checked before anything else.
1435
+ if (expectedDigest && downloadedBuffer) {
1436
+ const algo = expectedDigest.includes(':') ? expectedDigest.split(':')[0] : 'sha256';
1437
+ const actual = `${algo}:${createHash(algo).update(downloadedBuffer).digest('hex')}`;
1438
+ if (actual !== expectedDigest) {
1439
+ return damaged(`downloaded bundle digest ${actual} does not match the release-declared digest ${expectedDigest}`, landed);
1440
+ }
1441
+ }
1442
+
1443
+ const fingerprint = (r) => `${r.builtUtc || ''}|${r.sourceCommit || ''}|${r.sourceDescribe || ''}`;
1444
+ const storeUnchanged = Boolean(before) && fingerprint(landed) === fingerprint(before);
1445
+
1446
+ const landedBundle = bundleIdentity(landedSource);
1447
+ const priorBundle = bundleIdentity(beforeBundle);
1448
+ // null = one side carries no bundle identity at all (a pre-releaseTag bundle, or a plain forge
1449
+ // manifest). There is then nothing to compare, so the per-store fingerprint is the ONLY signal
1450
+ // available and the original behaviour stands — a fallback, never the primary test.
1451
+ const bundleChanged = (landedBundle && priorBundle) ? landedBundle !== priorBundle : null;
1452
+
1453
+ const nothingMoved = bundleChanged === null ? storeUnchanged : (!bundleChanged && storeUnchanged);
1454
+ if (nothingMoved) {
1455
+ const detail = bundleChanged === null
1456
+ ? `store "${kbName}" on disk is IDENTICAL to before the update (built ${landed.builtUtc || '?'}` +
1457
+ `${landed.sourceDescribe ? `, ${landed.sourceDescribe}` : ''}), and this bundle carries no top-level identity to cross-check`
1458
+ : `the BUNDLE on disk is IDENTICAL to before the update (${landedBundle}) and store "${kbName}" did not move either`;
1459
+ return {
1460
+ ok: false,
1461
+ kind: 'noop',
1462
+ reason: `${detail} — nothing actually changed. Bytes were replaced with an identical copy while the ` +
1463
+ `download reported success: issue #35, and the reason issue #106 must not exit 0.`,
1464
+ landed,
1465
+ storeUnchanged,
1466
+ bundleChanged,
1467
+ };
1468
+ }
1469
+
1470
+ return { ok: true, kind: null, reason: null, landed, storeUnchanged, bundleChanged };
1471
+ }
1472
+
1473
+ async function main() {
1474
+ if (APPLY) {
1475
+ try { updateLock = acquireUpdateLock(); }
1476
+ catch (error) { die(`update lock refused this run: ${error.message}`); }
1477
+
1478
+ // Cleanup is part of every apply, including an already-current run. Otherwise a redundant
1479
+ // multi-GB rollback can survive forever simply because there is no newer release to trigger
1480
+ // the old behind-only preflight.
1481
+ const preflightRollbacks = reclaimBackups({ kbDir: KB_DIR });
1482
+ legacyBackupRetention = preflightRollbacks;
1483
+ if (preflightRollbacks.removed.length) {
1484
+ console.log(`\nreleased ${preflightRollbacks.removed.length} redundant rollback ${preflightRollbacks.removed.length === 1 ? 'copy' : 'copies'} before update check`);
1485
+ }
1486
+ // Only an UNMEASURED or genuinely lost copy is recovery state. Being over the retention budget
1487
+ // with copies that are measured and pinned for a stated reason (a fenced private store, bytes
1488
+ // the proof could not match) is reported, not fatal — this update adds no persistent copy of
1489
+ // its own, and a refusal here was what kept --apply from ever running (measured 2026-09-11).
1490
+ const reasonFor = (backup) => preflightRollbacks.kept.find(([b]) => b === backup)?.[1] || 'not measured';
1491
+ if (preflightRollbacks.blockingRetained.length) {
1492
+ const detail = preflightRollbacks.blockingRetained.map(({ path: backup }) => ` ${backup}: ${reasonFor(backup)}`).join('\n');
1493
+ die(`unresolved rollback state exists; refusing to create another full-KB copy.\n${detail}\n Restore or reconcile that copy first, then re-run.`);
1494
+ }
1495
+ if (preflightRollbacks.overBudget) {
1496
+ const { snapshots, maxSnapshots, bytes, maxBytes } = preflightRollbacks.overBudget;
1497
+ console.log(`\nOVER_BUDGET: ${snapshots} retained full-KB ${snapshots === 1 ? 'copy' : 'copies'} (${bytes} bytes) exceed the budget of ${maxSnapshots} (${maxBytes} bytes). Each is measured and pinned for the reason below; this update proceeds without adding a persistent copy.`);
1498
+ }
1499
+ for (const retained of preflightRollbacks.retained) {
1500
+ console.log(`\nRETAINED: ${retained.path} (${retained.bytes} bytes) — ${reasonFor(retained.path)}`);
1501
+ }
1502
+ }
1503
+ const canon = await fetchJson(manifestUrl);
1504
+
1505
+ // ── CORPUS-RELEASE COMPATIBILITY GATE (ADR-086 step 16) ────────────────────────────────────────
1506
+ // Runs BEFORE the behind/current report, so `--check` refuses on exactly the same terms `--apply`
1507
+ // does, and before a single byte of bundle is fetched. Two tiers:
1508
+ // 1. this brain cannot prove which approved runtime it is running -> refuse, zero bandwidth;
1509
+ // 2. this exact tag was already refused and nothing has changed -> refuse, zero bandwidth.
1510
+ // The third tier (the staged bundle was built by a different runtime) can only be decided from
1511
+ // the downloaded bytes, and is enforced after extraction — where it also writes the ledger tier 2
1512
+ // reads, so a refusal costs one download ONCE rather than one download a night.
1513
+ const canonTag = isGithubReleasePayload(canon) ? canon.tag_name : null;
1514
+ const corpusRelease = isCorpusReleaseTag(canonTag);
1515
+ let installedRuntimeVersion = null;
1516
+ if (corpusRelease) {
1517
+ const runtime = readInstalledRuntime(KB_DIR);
1518
+ if (!runtime.ok) {
1519
+ die(`INCOMPATIBLE corpus release ${canonTag}\n`
1520
+ + ` ${runtime.reason}\n`
1521
+ + ` A corpus release carries knowledge for ONE approved runtime. This brain cannot prove which\n`
1522
+ + ` runtime it is running, so nothing was downloaded and nothing on disk was changed.\n`
1523
+ + ` Fix it with: npx ruvnet-brain (re-runs the installer, which re-stamps the approved runtime)`, 5);
1524
+ }
1525
+ installedRuntimeVersion = runtime.brainVersion;
1526
+ const remembered = readRejectedRelease(KB_DIR);
1527
+ if (remembered && remembered.tag === canonTag && remembered.installedRuntime === installedRuntimeVersion) {
1528
+ die(`corpus release ${canonTag} was already rejected by this brain (${remembered.rejectedUtc})\n`
1529
+ + ` ${remembered.reason}\n`
1530
+ + ` Nothing was downloaded. This refusal is remembered on purpose: rediscovering it every night\n`
1531
+ + ` would be a download loop with extra steps. It clears itself when a compatible release is\n`
1532
+ + ` published, or when this brain moves to a different runtime.\n`
1533
+ + ` Fix it with: npx ruvnet-brain (installs the code release that corpus was built for)`, 5);
1534
+ }
1535
+ }
1536
+
1537
+ const activeProfile = RESTORE_COMPLETE ? 'complete' : readBrainProfile();
1538
+ const profileStores = selectUpdateManagedStores(stores, activeProfile);
1539
+ if (activeProfile === 'ruvector' && profileStores.length === 0) {
1540
+ die(`SOURCE.json has no ruvector store, so the selected RuVector Only profile cannot update safely.`);
1541
+ }
1542
+ const targets = ONLY ? profileStores.filter((s) => s.kbName === ONLY) : profileStores;
1543
+ if (ONLY && targets.length === 0) die(`SOURCE.json has no store named "${ONLY}". Known: ${stores.map((s) => s.kbName).join(', ')}`);
1544
+
1545
+ const canonLabel = canon.tag_name
1546
+ ? `${canon.tag_name} (published ${canon.published_at || canon.created_at || '?'})`
1547
+ : canon.generated || canon.builtUtc || '(unknown)';
1548
+ console.log(`\n=== rvf-kb-forge evergreen check ===`);
1549
+ console.log(`canonical manifest: ${manifestUrl}`);
1550
+ console.log(`canonical built: ${canonLabel}\n`);
1551
+
1552
+ // ── ONE CURRENCY VERDICT (S2) ──────────────────────────────────────────────────────────────────
1553
+ // Computed ONCE for the whole bundle, from data already fetched — before a single byte of the
1554
+ // archive is downloaded. --check, --apply, and every store within a single run share this exact
1555
+ // decision (bundleIdentity() already established that every store in a release shares its identity;
1556
+ // the mixed-generation refusal a few lines below enforces that the resolved download target agrees).
1557
+ const installedIdentity = installedCurrencyIdentity(source);
1558
+ const candidateIdentity = candidateCurrencyIdentity(canon);
1559
+ const verdict = RESTORE_COMPLETE
1560
+ ? { verdict: 'UPDATE_AVAILABLE', reason: '--restore-complete forces a full profile restore' }
1561
+ : currencyVerdict(installedIdentity, candidateIdentity);
1562
+ console.log(`currency verdict: ${verdict.verdict} — ${verdict.reason}\n`);
1563
+
1564
+ // REFUSED is rollback protection: the candidate is a corpus generation strictly OLDER than what is
1565
+ // installed. Nothing is downloaded, the live tree is untouched, and this is a clean success (exit
1566
+ // 0) in BOTH modes — never exit 10, which would invite --apply into refusing again.
1567
+ if (verdict.verdict === 'REFUSED') {
1568
+ console.log(`REFUSED — ${verdict.reason}`);
1569
+ console.log('Nothing was downloaded; the live brain is untouched.');
1570
+ const refusedOutcome = APPLY
1571
+ ? writeUpdateOutcome({ terminalVerdict: 'refused', reason: verdict.reason,
1572
+ currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
1573
+ storeCount: targets.length })
1574
+ : writeCheckOutcome({ currencyVerdict: verdict.verdict, currencyReason: verdict.reason,
1575
+ candidateKind: candidateIdentity.kind, storeCount: targets.length });
1576
+ if (refusedOutcome?.terminalVerdict === 'recovery-required') die(refusedOutcome.reason);
1577
+ process.exit(0);
1578
+ }
1579
+
1580
+ let anyBehind = false; const behindStores = [];
1581
+ for (const local of targets) {
1582
+ const c = canonicalFor(canon, local.kbName);
1583
+ const behind = verdict.verdict !== 'CURRENT';
1584
+ anyBehind = anyBehind || behind;
1585
+ if (behind) {
1586
+ behindStores.push({ local });
1587
+ console.log(`[${local.kbName}] BEHIND`);
1588
+ console.log(` canonical: built ${c.builtUtc} from ${short(c.sourceCommit)}${c.sourceDescribe ? ` (${c.sourceDescribe})` : ''}`);
1589
+ console.log(` yours: built ${local.builtUtc} from ${short(local.sourceCommit)}${local.sourceDescribe ? ` (${local.sourceDescribe})` : ''}`);
1590
+ } else {
1591
+ console.log(`[${local.kbName}] UP TO DATE (built ${local.builtUtc || '?'} from ${short(local.sourceCommit)})`);
1592
+ }
1593
+ }
1594
+
1595
+ if (!APPLY) {
1596
+ writeCheckOutcome({ currencyVerdict: verdict.verdict, currencyReason: verdict.reason,
1597
+ candidateKind: candidateIdentity.kind, storeCount: targets.length });
1598
+ if (anyBehind) { console.log(`\nA newer build exists. Run: node forge-update.mjs --apply`); process.exit(10); }
1599
+ console.log(`\nAll stores current. Nothing to do.`); process.exit(0);
1600
+ }
1601
+
1602
+ if (!anyBehind) {
1603
+ const inventoryBefore = managedStorageInventory(KB_DIR);
1604
+ let validateCoverageDirectory;
1605
+ try { validateCoverageDirectory = await loadTrustedCoverageValidator(); }
1606
+ catch (error) { die(`${error.message}. The live KB is untouched.`); }
1607
+ const installed = activeProfile === 'complete'
1608
+ ? validateReleaseCoverageTree(KB_DIR, validateCoverageDirectory)
1609
+ : validateProfiledReleaseTree(KB_DIR, activeProfile, capturePrivateOverlayState({ kbDir: KB_DIR, allStores: stores }));
1610
+ if (!installed.valid) die(`already-current KB failed integrity: ${installed.failures.join('; ')}`);
1611
+ // No transaction paths are created: the inventory still counts every retained managed copy.
1612
+ const measuredDelta = storageDelta({ live: KB_DIR }, { prior: inventoryBefore.active, inventoryBefore });
1613
+ const noopOutcome = writeUpdateOutcome({ terminalVerdict: 'noop', reason: 'already-current', storeCount: targets.length,
1614
+ currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
1615
+ storageDelta: measuredDelta,
1616
+ phaseEvidence: phaseEvidenceFor({ root: KB_DIR, terminalVerdict: 'noop', storageDelta: measuredDelta }) });
1617
+ if (noopOutcome?.terminalVerdict === 'recovery-required') die(noopOutcome.reason);
1618
+ console.log(`\nNothing to apply — already current.`); process.exit(0);
1619
+ }
1620
+
1621
+ let validateCoverageDirectory;
1622
+ try { validateCoverageDirectory = await loadTrustedCoverageValidator(); }
1623
+ catch (error) { die(`${error.message}. The live KB is untouched.`); }
1624
+
1625
+ // What actually landed for each store, so the final message (below RECLAIM) can be built from
1626
+ // the real on-disk artifact instead of the `canon` lookup made at the top of this run — issue
1627
+ // #35 item 2. Populated by verifyLanded() as each store is applied; main() dies loudly before
1628
+ // reaching the summary if any store's landed copy does not check out.
1629
+ const landedByStore = new Map();
1630
+ // Stores that landed byte-identical because their upstream repo did not move. ORDINARY, and named
1631
+ // in the summary so "unchanged" never has to be inferred from silence (issue #108).
1632
+ const unchangedStores = [];
1633
+ // Stores whose bundle demonstrably did not move at all. Non-zero exit, counted in the summary
1634
+ // rather than only in the mid-log line a cron job never reads (issue #106).
1635
+ let privateOverlay;
1636
+ try { privateOverlay = capturePrivateOverlayState({ kbDir: KB_DIR, allStores: stores }); }
1637
+ catch (e) { die(`private overlay preflight failed: ${e.message} — refusing to update.`); }
1638
+ const resolvedTargets = behindStores.map(({ local }) => ({ local, resolved: resolveBundleUrl({ canon, local, source }) }));
1639
+ for (const { local, resolved } of resolvedTargets) {
1640
+ if (!resolved.url) die(`[${local.kbName}] no canonical bundle URL is resolvable from the live manifest.`);
1641
+ if (resolved.warning) console.warn(`\n ⚠ ${resolved.warning}`);
1642
+ }
1643
+ const bundleIdentities = new Set(resolvedTargets.map(({ resolved }) => JSON.stringify({ url: resolved.url, digest: resolved.digest || null })));
1644
+ if (bundleIdentities.size !== 1) {
1645
+ die(`selected stores resolve to divergent combined bundle identities; refusing a mixed-generation update.`);
1646
+ }
1647
+ const resolved = resolvedTargets[0].resolved;
1648
+ const originLabel = resolved.origin === 'latest-release-asset' ? `live release asset "${resolved.assetName}"`
1649
+ : resolved.origin === 'live-manifest' ? 'live manifest' : 'PINNED FALLBACK (see warning above)';
1650
+ console.log(`\n[${behindStores.length} store(s)] downloading ${resolved.url}\n (source: ${originLabel}) ...`);
1651
+ const buf = await fetchBuffer(resolved.url);
1652
+ const sigBuf = await fetchBuffer(`${resolved.url}.sig`, { failureCode: 3, kind: 'signature' });
1653
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-update-release-'));
1654
+ const zipPath = path.join(tmp, 'bundle.zip');
1655
+ const sigPath = path.join(tmp, 'bundle.zip.sig');
1656
+ const extractDir = path.join(tmp, 'extracted');
1657
+ fs.writeFileSync(zipPath, buf);
1658
+ fs.writeFileSync(sigPath, sigBuf);
1659
+ fs.mkdirSync(extractDir);
1660
+ console.log(` downloaded ${(buf.length / 1e6).toFixed(1)} MB.`);
1661
+ const signature = verifyDownloadedBundle(zipPath, sigPath);
1662
+ if (!signature.ok) { fs.rmSync(tmp, { recursive: true, force: true }); die(`✗ SIGNATURE VERIFICATION FAILED: ${signature.reason}`, 4); }
1663
+ console.log(` ✓ signature verified — ${signature.reason}`);
1664
+ try { await extractZip(zipPath, extractDir); }
1665
+ catch (error) { fs.rmSync(tmp, { recursive: true, force: true }); die(`extraction failed: ${error.message} — local files untouched.`); }
1666
+ // ── TIER 3: the staged bundle names the runtime that built it; this client names the runtime it
1667
+ // measurably runs. They must be equal, or this corpus is not for this brain. Refused BEFORE the
1668
+ // storage transaction, so the live tree is never touched, and REMEMBERED so the next run refuses
1669
+ // without downloading again.
1670
+ if (corpusRelease) {
1671
+ let stagedRuntimeVersion = null;
1672
+ try { stagedRuntimeVersion = JSON.parse(fs.readFileSync(path.join(extractDir, 'SOURCE.json'), 'utf8')).brainVersion || null; }
1673
+ catch (error) {
1674
+ fs.rmSync(tmp, { recursive: true, force: true });
1675
+ die(`corpus release ${canonTag} has no readable SOURCE.json: ${error.message} — local files untouched.`);
1676
+ }
1677
+ const compatible = assertCorpusReleaseCompatible({ kbDir: KB_DIR, offeredRuntimeVersion: stagedRuntimeVersion });
1678
+ if (!compatible.ok) {
1679
+ writeRejectedRelease(KB_DIR, { tag: canonTag, reason: compatible.reason, installedRuntime: installedRuntimeVersion });
1680
+ fs.rmSync(tmp, { recursive: true, force: true });
1681
+ die(`INCOMPATIBLE corpus release ${canonTag}\n`
1682
+ + ` ${compatible.reason}\n`
1683
+ + ` Nothing was installed and the live brain is untouched. Installing it would have put knowledge\n`
1684
+ + ` built for a different runtime behind this one's reader.\n`
1685
+ + ` This refusal is now remembered, so tonight's check will not download it again.\n`
1686
+ + ` Fix it with: npx ruvnet-brain (installs the code release that corpus was built for)`, 5);
1687
+ }
1688
+ }
1689
+ const stagedCoverage = validateReleaseCoverageTree(extractDir, validateCoverageDirectory, installedRuntimeVersion);
1690
+ if (!stagedCoverage.valid) {
1691
+ fs.rmSync(tmp, { recursive: true, force: true });
1692
+ die(`staged ReleaseCoverage failed integrity: ${stagedCoverage.failures.join('; ')} — local files untouched.`);
1693
+ }
1694
+
1695
+ const privateNames = Object.keys(privateOverlay?.sourceStores || {});
1696
+ let profileResult = null;
1697
+ const finalVerificationByStore = new Map();
1698
+ const validateFinalTree = ({ dir, phase }) => {
1699
+ const coverageResult = activeProfile === 'complete'
1700
+ ? validateReleaseCoverageTree(dir, validateCoverageDirectory, installedRuntimeVersion)
1701
+ : validateProfiledReleaseTree(dir, activeProfile, privateOverlay);
1702
+ if (!coverageResult.valid) return coverageResult;
1703
+ const guard = path.join(dir, 'forge-guard.mjs');
1704
+ if (!fs.existsSync(guard)) return { valid: false, failures: ['forge-guard.mjs is missing'] };
1705
+ try {
1706
+ for (const { local, resolved: storeResolution } of resolvedTargets) {
1707
+ execFileSync(process.execPath, [guard, '--dir', dir, '--name', local.kbName], { cwd: dir, stdio: 'pipe' });
1708
+ const verified = verifyLanded({ kbDir: dir, kbName: local.kbName, before: local, beforeBundle: source,
1709
+ expectedDigest: storeResolution.digest, downloadedBuffer: buf });
1710
+ if (!verified.ok && verified.kind !== 'noop') return { valid: false, failures: [verified.reason] };
1711
+ if (phase === 'live') finalVerificationByStore.set(local.kbName, verified);
1712
+ }
1713
+ return { valid: true, failures: [] };
1714
+ } catch (error) { return { valid: false, failures: [`forge-guard failed: ${describeGuardFailure(error)}`] }; }
1715
+ };
1716
+ let transaction;
1717
+ try {
1718
+ // The installer starts this child inside KB_DIR. Windows holds that directory open
1719
+ // until cwd leaves it, preventing the atomic swap. Inputs (including RESULT_FILE)
1720
+ // are already resolved; all transaction and recovery paths remain absolute.
1721
+ const cwdWithinKb = path.relative(KB_DIR, process.cwd());
1722
+ if (cwdWithinKb === '' || (!path.isAbsolute(cwdWithinKb)
1723
+ && cwdWithinKb !== '..' && !cwdWithinKb.startsWith(`..${path.sep}`))) {
1724
+ process.chdir(path.dirname(KB_DIR));
1725
+ }
1726
+ transaction = runStorageTransaction({ liveDir: KB_DIR, sourceDir: extractDir,
1727
+ transactionId: `${Date.now()}-${process.pid}`,
1728
+ prepareCandidate: ({ candidateDir, liveDir }) => {
1729
+ // The trusted coverage validator is installer-provided and never ships inside the bundle it
1730
+ // judges (build-bundle cannot see this file's dynamic load). Carry the LIVE copy into the
1731
+ // candidate — never the bundle's: a promoted generation without it strands the next --apply
1732
+ // on "installed coverage validator is missing", and a byte-identical bundle would stop
1733
+ // reading as a no-op merely because live holds the one file the bundle cannot. Measured
1734
+ // 2026-09-12: every 4.3.21 brain lacked it, so this branch had never once run to completion.
1735
+ const liveValidator = path.join(liveDir, 'coverage-integrity.mjs');
1736
+ if (fs.existsSync(liveValidator)) {
1737
+ fs.copyFileSync(assertNoFollowPath(liveDir, liveValidator),
1738
+ assertNoFollowPath(candidateDir, path.join(candidateDir, 'coverage-integrity.mjs')));
1739
+ }
1740
+ // RUNTIME-IDENTITY.json is installer-written and, like the validator above, never ships
1741
+ // inside a bundle — so an exact-tree promotion would DELETE it and the very next corpus
1742
+ // check would refuse with "no installed runtime identity". Same lesson, same fix: carry the
1743
+ // LIVE copy into the candidate. (It pins coverage-integrity.mjs, which was just carried
1744
+ // across unchanged, so the pin still verifies on the promoted tree.)
1745
+ const liveRuntimeIdentity = path.join(liveDir, 'RUNTIME-IDENTITY.json');
1746
+ if (fs.existsSync(liveRuntimeIdentity)) {
1747
+ fs.copyFileSync(assertNoFollowPath(liveDir, liveRuntimeIdentity),
1748
+ assertNoFollowPath(candidateDir, path.join(candidateDir, 'RUNTIME-IDENTITY.json')));
1749
+ }
1750
+ // node_modules (the ONNX embedder and RVF readers) is installer-placed and never ships inside a
1751
+ // bundle, the same class as the two files above. The candidate is validated by forge-guard in a
1752
+ // SIBLING directory with no parent node_modules, so without it the guard cannot load the
1753
+ // embedder and every apply fails; and the exact-tree swap would delete it from the live KB.
1754
+ // Reflink clone where the filesystem supports it (APFS/btrfs), plain copy otherwise.
1755
+ const liveModules = path.join(liveDir, 'node_modules');
1756
+ if (fs.existsSync(liveModules) && !fs.existsSync(path.join(candidateDir, 'node_modules'))) {
1757
+ fs.cpSync(assertNoFollowPath(liveDir, liveModules), path.join(candidateDir, 'node_modules'),
1758
+ { recursive: true, verbatimSymlinks: true, mode: fs.constants.COPYFILE_FICLONE });
1759
+ }
1760
+ restorePrivateFilesIntoCandidate({ candidateDir, sourceDir: liveDir, overlay: privateOverlay });
1761
+ // ATOMIC WITH INSTALLATION, not after it. The transport identity is written INTO the
1762
+ // candidate, so the storage transaction's single rename either promotes the bytes AND the
1763
+ // record of where they came from, or promotes neither. A crash here cannot leave a tree
1764
+ // whose contents and whose declared provenance disagree — which is the whole reason this is
1765
+ // not a second write against the live tree once the swap has happened.
1766
+ if (canonTag) {
1767
+ recordCorpusTransportIdentity(candidateDir, { releaseTag: canonTag });
1768
+ // S2: the generation ordering key is stamped ATOMICALLY alongside the transport tag — same
1769
+ // candidate directory, same single rename into place. A crash between the two can never
1770
+ // leave a tree whose transport tag and generation ordering key disagree.
1771
+ recordCorpusGenerationIdentity(candidateDir, { corpusReleaseTag: canonTag,
1772
+ generation: candidateIdentity.corpusGeneration });
1773
+ }
1774
+ const fullCoverage = validateReleaseCoverageTree(candidateDir, validateCoverageDirectory, installedRuntimeVersion);
1775
+ if (!fullCoverage.valid) throw new Error(`candidate public/private convergence failed: ${fullCoverage.failures.join('; ')}`);
1776
+ if (activeProfile !== 'complete') profileResult = applyBrainProfile(candidateDir, activeProfile, { preserveStores: privateNames });
1777
+ },
1778
+ validateCandidate: validateFinalTree,
1779
+ validateLive: validateFinalTree,
1780
+ });
1781
+ } catch (error) {
1782
+ fs.rmSync(tmp, { recursive: true, force: true });
1783
+ die(`storage transaction failed: ${error.message}`);
1784
+ }
1785
+ fs.rmSync(tmp, { recursive: true, force: true });
1786
+ console.log(` storage transaction: ${transaction.terminalVerdict}`);
1787
+ if (transaction.terminalVerdict === 'cleanup-pending') {
1788
+ const bundleSha256 = createHash('sha256').update(buf).digest('hex');
1789
+ const cleanupOutcome = writeUpdateOutcome({ terminalVerdict: 'cleanup-pending', storageDelta: transaction.storageDelta,
1790
+ transactionReceipts: transaction.paths.receipts, bundleSha256,
1791
+ phaseEvidence: phaseEvidenceFor({ root: KB_DIR, terminalVerdict: 'cleanup-pending', bundleSha256,
1792
+ transactionReceipts: transaction.paths.receipts, overlay: privateOverlay,
1793
+ storageDelta: transaction.storageDelta }) });
1794
+ if (cleanupOutcome?.terminalVerdict === 'recovery-required') die(cleanupOutcome.reason);
1795
+ console.error('\nVerified live generation is active, but redundant rollback cleanup is pending.');
1796
+ process.exitCode = 12;
1797
+ return;
1798
+ }
1799
+ for (const { local, resolved: storeResolution } of resolvedTargets) {
1800
+ const verified = finalVerificationByStore.get(local.kbName)
1801
+ || verifyLanded({ kbDir: KB_DIR, kbName: local.kbName, before: local, beforeBundle: source,
1802
+ expectedDigest: storeResolution.digest, downloadedBuffer: buf });
1803
+ if (verified.storeUnchanged || verified.kind === 'noop') unchangedStores.push(local.kbName);
1804
+ landedByStore.set(local.kbName, { landed: verified.landed, origin: storeResolution.origin, assetName: storeResolution.assetName });
1805
+ }
1806
+
1807
+ const intentionallyRemovedStores = profileResult?.removedStores || [];
1808
+ if (profileResult) {
1809
+ console.log(`\nprofile ${activeProfile}: kept ${profileResult.stores.join(', ')}; removed ${profileResult.removed.length} unselected artifact(s).`);
1810
+ }
1811
+
1812
+ // ── RECLAIM THE ROLLBACK COPY (issue #35, Dr. Mark Allen) ──────────────────────────────────────
1813
+ // The rollback copy exists to survive the SWAP, not to live on disk forever. Every update used to
1814
+ // leave a full ~2.5 GB copy behind and never remove it; Mark accumulated SEVEN (~14 GB) before
1815
+ // noticing. By this point forge-guard has PROVEN the new copy answers, and the bundle it came from
1816
+ // is a signed, versioned, re-downloadable artifact — so the old copy is dead weight. Released here,
1817
+ // and any copies stranded by earlier runs are swept with it.
1818
+ //
1819
+ // THE ONE CASE WHERE IT IS NOT DEAD WEIGHT, and why this is a check and not an `rm`: a KB can hold
1820
+ // stores the public bundle does not ship (private/local ones). The update replaces the directory, so
1821
+ // if such a store is absent from the new copy, the backup is its ONLY remaining copy — and
1822
+ // forge-guard would still pass, because it verifies the store it was asked about, not whatever went
1823
+ // missing. Deleting there would destroy the only copy of a user's private data. So: compare store
1824
+ // inventories first, and keep any backup holding something the new copy lost.
1825
+ //
1826
+ // Routed through settleRollback() so this is the SAME release the failure paths use, rather than a
1827
+ // happy-path-only call that a die() can step over — that step-over is issue #108.
1828
+ settleRollback({ reclaimable: true, intentionallyRemovedStores });
1829
+
1830
+ // A release actually landed, so any remembered refusal is spent history — never a permanent
1831
+ // blocklist. Clearing it here (and only here) means the ledger can only ever suppress a repeat of
1832
+ // the exact refusal that produced it.
1833
+ clearRejectedRelease(KB_DIR);
1834
+
1835
+ // ── FINAL MESSAGE — DERIVED FROM WHAT LANDED, NOT FROM THE TAG LOOKUP (issue #35 item 2) ────────
1836
+ // The old line above printed `canon.tag_name` regardless of what the download actually contained
1837
+ // — that is verbatim the bug: "printed 'KB updated to the canonical build (v3.4.21-dev)' [...]
1838
+ // SOURCE.json still said v0.5.0-dev afterward." Every store reaching this line already passed
1839
+ // verifyLanded() above (main() dies before this point otherwise), so what follows is read back
1840
+ // from the real file on disk, not asserted.
1841
+ console.log(transaction.terminalVerdict === 'noop'
1842
+ ? `\n=== DONE — exact no-op; installed bytes already equal the validated candidate ===`
1843
+ : `\n=== DONE — ${behindStores.length} store(s) updated ===`);
1844
+ console.log(`resolved target (live manifest, checked BEFORE downloading): ${canonLabel}`);
1845
+ for (const { local } of behindStores) {
1846
+ const r = landedByStore.get(local.kbName);
1847
+ const l = r.landed;
1848
+ console.log(`[${local.kbName}] SOURCE.json on disk now reads: built ${l.builtUtc || '?'} from ${short(l.sourceCommit)}${l.sourceDescribe ? ` (${l.sourceDescribe})` : ''}`);
1849
+ console.log(` fetched from: ${r.origin === 'latest-release-asset' ? `release asset "${r.assetName}"` : r.origin === 'live-manifest' ? 'live manifest entry' : 'PINNED FALLBACK — see warning above'}`);
1850
+ }
1851
+ if (unchangedStores.length) {
1852
+ // NAMED, not silent. Stores are forged independently, so a store whose upstream repo did not
1853
+ // move is re-shipped byte-identical inside a genuinely new bundle — normal, and the thing that
1854
+ // used to abort the whole run (issue #108).
1855
+ console.log(`\n${unchangedStores.length} of ${behindStores.length} store(s) were already at the canonical build and did not change: ${unchangedStores.join(', ')}`);
1856
+ console.log(` (stores are forged independently — an unchanged store means its upstream repo did not move, not a failed update.)`);
1857
+ }
1858
+ const finalOutcome = writeUpdateOutcome({ terminalVerdict: transaction.terminalVerdict, storeCount: behindStores.length,
1859
+ currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
1860
+ storageDelta: transaction.storageDelta,
1861
+ transactionReceipts: transaction.paths.receipts,
1862
+ bundleSha256: createHash('sha256').update(buf).digest('hex'),
1863
+ coverageSha256: sha256File(path.join(KB_DIR, 'COVERAGE.json')),
1864
+ phaseEvidence: phaseEvidenceFor({ root: KB_DIR, terminalVerdict: transaction.terminalVerdict,
1865
+ bundleSha256: createHash('sha256').update(buf).digest('hex'),
1866
+ transactionReceipts: transaction.paths.receipts, overlay: privateOverlay,
1867
+ storageDelta: transaction.storageDelta }) });
1868
+ if (finalOutcome?.terminalVerdict === 'recovery-required') die(finalOutcome.reason);
1869
+ console.log(`\n(the above is read back from disk, verified — not a tag lookup)`);
1870
+ process.exit(0);
1871
+ }
1872
+
1873
+ // Run ONLY when executed directly. `reclaimBackups` is exported for its own tests, and without this
1874
+ // guard merely importing this file would start a live update — a network fetch, a directory swap, and
1875
+ // a process.exit() inside whatever imported it. (Found exactly that way: the reclaim test's import
1876
+ // began racing a real update against the test run.)
1877
+ const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
1878
+ if (invokedDirectly && STAGED_RELEASE_FILE) {
1879
+ (async () => {
1880
+ const input = JSON.parse(fs.readFileSync(STAGED_RELEASE_FILE, 'utf8'));
1881
+ const result = await applyVerifiedStagedRelease(input);
1882
+ console.log(JSON.stringify({ schemaVersion: 1, kind: 'ruvnet-brain-staged-recovery', ...result }));
1883
+ })().catch((e) => die(`staged recovery failed: ${e.message}`));
1884
+ } else if (invokedDirectly) main().catch((e) => die(`unexpected: ${e.message}`));