ruvnet-brain 4.7.11 → 4.7.13

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.
package/README.md CHANGED
@@ -9,7 +9,7 @@ Created: 2026-06-29 22:36:38 EDT
9
9
 
10
10
  ### Discover the stack. Read the source. Build with confidence.
11
11
 
12
- [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.11-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](plugin/.claude-plugin/plugin.json)
12
+ [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.13-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](plugin/.claude-plugin/plugin.json)
13
13
 
14
14
  **A source-grounded companion for Claude Code and Codex that helps you find and use Reuven Cohen's (rUv's) RuvNet ecosystem.**
15
15
 
@@ -255,7 +255,7 @@ uses the protected release workflow; a consumer update downloads an available ge
255
255
  does not rebuild upstream knowledge. Check `--doctor`, `SOURCE.json` and refresh receipts
256
256
  before treating an installation as current.
257
257
 
258
- The **4.6.0 published-corpus baseline** is **199 public stores · 162,680 public source chunks**.
258
+ The **4.6.0 published-corpus baseline** is **203 public stores · 164,401 public source chunks**.
259
259
  Those are bundle census values, not a claim that every upstream repository is fresh today.
260
260
  Later corpus publication may advance the knowledge generation independently of code changes.
261
261
 
package/bin/install.mjs CHANGED
@@ -1770,9 +1770,29 @@ function legacyBrainWrapperOwnership(wrapperPath, home) {
1770
1770
  if (installed && shippedByRetainedGeneration(installed, path.join(home, '.cache', 'ruvnet-brain', 'versions'))) {
1771
1771
  return { state: 'prior-generation' };
1772
1772
  }
1773
+ // A PRUNED generation's bridge (owner's machine, 2026-10-17): the bytes an EARLIER shipped release wrote,
1774
+ // whose versions/<v>/ copy is gone (pruned, or written by a release that predates the Spine). The proof
1775
+ // is the committed history of every wrapper each release tag carried, derived from git by
1776
+ // scripts/codex-hook-wrapper-history.mjs and checked against git in the unit suite. Bytes only: the
1777
+ // path, link, inode and uid checks above have already passed by the time this is consulted.
1778
+ const past = installed && shippedByPastRelease(installed);
1779
+ if (past) return { state: 'prior-release', release: past.tags[past.tags.length - 1] };
1773
1780
  return { state: 'conflict', reason: 'existing bridge differs from the shipped Brain source' };
1774
1781
  }
1775
1782
 
1783
+ // Test seam only: RUVNET_WRAPPER_HISTORY_FILE points the proof at another history file, so the unit suite
1784
+ // can hand the installer a malformed or hostile history without touching the committed data file.
1785
+ const WRAPPER_HISTORY_FILE = path.join(REPO_ROOT, 'data', 'codex-hook-wrapper-history.json');
1786
+ function shippedByPastRelease(bytes, historyFile = process.env.RUVNET_WRAPPER_HISTORY_FILE || WRAPPER_HISTORY_FILE) {
1787
+ let history;
1788
+ try { history = JSON.parse(fs.readFileSync(historyFile, 'utf8')); } catch { return null; }
1789
+ if (history?.schemaVersion !== 1 || history.kind !== 'ruvnet-brain-codex-hook-wrapper-history' || !Array.isArray(history.wrappers)) return null;
1790
+ const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
1791
+ const hit = history.wrappers.find((row) => row?.sha256 === sha256 && row.bytes === bytes.length
1792
+ && Array.isArray(row.tags) && row.tags.length > 0 && row.tags.every((tag) => /^v\d+\.\d+\.\d+$/.test(tag)));
1793
+ return hit || null;
1794
+ }
1795
+
1776
1796
  function shippedByRetainedGeneration(bytes, versionsDir) {
1777
1797
  const ownedDir = (dir) => {
1778
1798
  try {
@@ -1907,7 +1927,7 @@ export function wireCodexHost({
1907
1927
  const currentOwnership = legacyBrainWrapperOwnership(hookWrapperPath, path.dirname(codexDir));
1908
1928
  if (currentOwnership.state === 'conflict') throw new Error(currentOwnership.reason);
1909
1929
  hookRetirement = retireManagedHookRegistrations({ home: path.dirname(codexDir), codexDir, wrapperPath: hookWrapperPath });
1910
- if (currentOwnership.state === 'prior-generation') hookWrapperBackup = backupPriorWrapper(hookWrapperPath);
1930
+ if (currentOwnership.state === 'prior-generation' || currentOwnership.state === 'prior-release') hookWrapperBackup = backupPriorWrapper(hookWrapperPath);
1911
1931
  atomicReplace(hookWrapperPath, (tmp) => fs.copyFileSync(hookWrapperSource, tmp));
1912
1932
  } catch (error) {
1913
1933
  if (announce) warn(`could not install the Codex hook bridge at ${hookWrapperPath}: ${error.message}`);
@@ -2413,8 +2433,8 @@ export function retireManagedHookRegistrations({ home = os.homedir(), codexDir =
2413
2433
  let removed = 0;
2414
2434
  const changedFiles = [];
2415
2435
  const conflicts = [];
2416
- // A bridge a retained Brain generation shipped is as much the Brain's as the new source.
2417
- const verifiedWrapper = ['verified', 'prior-generation']
2436
+ // A bridge a retained Brain generation, or any earlier shipped release, wrote is as much the Brain's as the new source.
2437
+ const verifiedWrapper = ['verified', 'prior-generation', 'prior-release']
2418
2438
  .includes(legacyBrainWrapperOwnership(wrapperPath, path.dirname(codexDir)).state);
2419
2439
  const tokensOf = (command) => (command.match(/"[^"]*"|'[^']*'|[^\s]+/g) || [])
2420
2440
  .map((token) => token.replace(/^["']|["']$/g, ''));
@@ -0,0 +1,146 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "kind": "ruvnet-brain-codex-hook-wrapper-history",
4
+ "source": "plugin/scripts/codex-hook-wrapper.mjs",
5
+ "releaseTags": 86,
6
+ "wrappers": [
7
+ {
8
+ "sha256": "f4a3159e06e8d7783be4f14070ddc34d298ace6ab120d92456d987b4d451f0f7",
9
+ "bytes": 1309,
10
+ "tags": [
11
+ "v4.0.1",
12
+ "v4.0.2",
13
+ "v4.0.3",
14
+ "v4.0.4",
15
+ "v4.0.5"
16
+ ]
17
+ },
18
+ {
19
+ "sha256": "37accf7f90aa61e538cb2ef1d162e95636947a0f177c6194ebe938a477c5642c",
20
+ "bytes": 2392,
21
+ "tags": [
22
+ "v4.0.6",
23
+ "v4.0.7"
24
+ ]
25
+ },
26
+ {
27
+ "sha256": "53b1b27b98ba7d20777a2b2877ffb5d0348f5b8e69e8cc24215d2e0fb6e50939",
28
+ "bytes": 4904,
29
+ "tags": [
30
+ "v4.0.8"
31
+ ]
32
+ },
33
+ {
34
+ "sha256": "48599192eff873bc95d7f849e42ae3d4fcd2d218e4c98caede65e5ddf9333d67",
35
+ "bytes": 4991,
36
+ "tags": [
37
+ "v4.0.24",
38
+ "v4.0.28",
39
+ "v4.0.35",
40
+ "v4.0.36"
41
+ ]
42
+ },
43
+ {
44
+ "sha256": "d29208f812a90d1507f3c928bd22dd37569d1f13d45fb7ce09592fc8e2dd6bd5",
45
+ "bytes": 8692,
46
+ "tags": [
47
+ "v4.3.1",
48
+ "v4.3.8",
49
+ "v4.3.9"
50
+ ]
51
+ },
52
+ {
53
+ "sha256": "16cbfd7083d50ad36ac187096e8b1dea7d5784ce5083ec436d4e54e2ce4d74a5",
54
+ "bytes": 10151,
55
+ "tags": [
56
+ "v4.3.10",
57
+ "v4.3.11",
58
+ "v4.3.14",
59
+ "v4.3.15",
60
+ "v4.3.16",
61
+ "v4.3.17",
62
+ "v4.3.18",
63
+ "v4.3.19",
64
+ "v4.3.20",
65
+ "v4.3.21",
66
+ "v4.3.22",
67
+ "v4.3.25",
68
+ "v4.3.26",
69
+ "v4.3.27",
70
+ "v4.3.28",
71
+ "v4.3.29",
72
+ "v4.3.30",
73
+ "v4.3.31",
74
+ "v4.3.32",
75
+ "v4.3.33",
76
+ "v4.3.34",
77
+ "v4.3.35",
78
+ "v4.3.36",
79
+ "v4.3.37",
80
+ "v4.3.38"
81
+ ]
82
+ },
83
+ {
84
+ "sha256": "801d813bd76a06a85f6874a69d548d455a23d9c0d142b2f84712e37c5dd61566",
85
+ "bytes": 10441,
86
+ "tags": [
87
+ "v4.3.39",
88
+ "v4.3.40"
89
+ ]
90
+ },
91
+ {
92
+ "sha256": "d3ee284722aadd9699ba7b7ece624cd59c5dd4a773cd2d7300340d1a662d0a15",
93
+ "bytes": 10924,
94
+ "tags": [
95
+ "v4.4.0",
96
+ "v4.4.1",
97
+ "v4.5.0",
98
+ "v4.5.1",
99
+ "v4.5.2",
100
+ "v4.5.3",
101
+ "v4.5.4",
102
+ "v4.5.5",
103
+ "v4.5.6",
104
+ "v4.5.7",
105
+ "v4.5.8",
106
+ "v4.5.9",
107
+ "v4.5.10",
108
+ "v4.5.11",
109
+ "v4.5.12",
110
+ "v4.5.13",
111
+ "v4.5.14",
112
+ "v4.5.15",
113
+ "v4.5.16"
114
+ ]
115
+ },
116
+ {
117
+ "sha256": "ea953c7329ea7c99ad681ae216a2bc537ff9ae3d75a1bb8b5ab69706df9ea6d3",
118
+ "bytes": 12180,
119
+ "tags": [
120
+ "v4.5.17",
121
+ "v4.6.0",
122
+ "v4.6.1",
123
+ "v4.6.2",
124
+ "v4.6.3",
125
+ "v4.6.4"
126
+ ]
127
+ },
128
+ {
129
+ "sha256": "d3c650ec9f211629d8a8aadb4d4dd0016d9e3ee0cc7a989c86f5629fefef664d",
130
+ "bytes": 12940,
131
+ "tags": [
132
+ "v4.7.0",
133
+ "v4.7.1",
134
+ "v4.7.2",
135
+ "v4.7.3",
136
+ "v4.7.4",
137
+ "v4.7.5",
138
+ "v4.7.6",
139
+ "v4.7.7",
140
+ "v4.7.8",
141
+ "v4.7.9",
142
+ "v4.7.10"
143
+ ]
144
+ }
145
+ ]
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.11",
3
+ "version": "4.7.13",
4
4
  "description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -101,7 +101,7 @@
101
101
  "config/model-router/",
102
102
  "config/practical-rule-catalog.json",
103
103
  "tri-smart-skill/",
104
- "data/model-catalog.json",
104
+ "data/model-catalog.json","data/codex-hook-wrapper-history.json",
105
105
  "scripts/",
106
106
  "kb/verify-citation.mjs",
107
107
  "kb/retrieval-result.mjs",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
4
- "version": "4.7.11",
4
+ "version": "4.7.13",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.11",
3
+ "version": "4.7.13",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -67,6 +67,27 @@ function defaultNodeCandidates(env) {
67
67
  return [...new Set(out)].filter((c) => { try { return fs.statSync(c).isFile(); } catch { return false; } });
68
68
  }
69
69
 
70
+ // Where ruflo's native better-sqlite3 lives depends on the ruflo release. Measured 2026-10-17 with npm -g: ruflo
71
+ // 3.56.10 hoisted nothing (copies nested under @claude-flow/cli, @claude-flow/memory and agentdb), so probing only
72
+ // <ruflo>/node_modules/better-sqlite3 found nothing and node selection silently never ran; ruflo 3.57.0 hoists it.
73
+ // We probe the hoisted copy first, so a machine that has one behaves exactly as before, then the nested layouts.
74
+ // Every candidate is a fixed path under ruflo's own realpath. Copies can differ in version (3.57.0: hoisted 12.11.1,
75
+ // agentdb's nested 11.10.0), so the probed copy is a proxy for the one the writer loads, not proof of it.
76
+ const ADDON_DIRS = [
77
+ ['node_modules', 'better-sqlite3'],
78
+ ['node_modules', '@claude-flow', 'cli', 'node_modules', 'better-sqlite3'],
79
+ ['node_modules', '@claude-flow', 'memory', 'node_modules', 'better-sqlite3'],
80
+ ['node_modules', 'agentdb', 'node_modules', 'better-sqlite3'],
81
+ ];
82
+
83
+ function findAddon(packageRoot) {
84
+ for (const parts of ADDON_DIRS) {
85
+ const dir = path.join(packageRoot, ...parts);
86
+ try { if (fs.statSync(dir).isDirectory()) return dir; } catch { /* try the next layout */ }
87
+ }
88
+ return null;
89
+ }
90
+
70
91
  const nativeNodeCache = new Map();
71
92
  const PROBE_TIMEOUT_MS = 2000;
72
93
  const PROBE_BUDGET_MS = 3000;
@@ -91,8 +112,8 @@ export function rufloInvocationOrPlain(binary, args, opts) {
91
112
  export function nativeNodeFor(binary, { env = process.env, candidates } = {}) {
92
113
  let entry;
93
114
  try { entry = fs.realpathSync(binary); } catch { return null; }
94
- const addon = path.join(path.dirname(path.dirname(entry)), 'node_modules', 'better-sqlite3');
95
- try { if (!fs.statSync(addon).isDirectory()) return null; } catch { return null; }
115
+ const addon = findAddon(path.dirname(path.dirname(entry)));
116
+ if (!addon) return null;
96
117
 
97
118
  const list = candidates ?? defaultNodeCandidates(env);
98
119
  const cacheKey = `${entry}\0${list.join('\0')}`;
@@ -189,7 +189,8 @@ export function assembleCodeReleaseCorpus({
189
189
  }
190
190
  if (verified.degraded.carried.length || verified.degraded.missing.length) {
191
191
  fail(`generation ${descriptor.tag} is degraded (${verified.degraded.carried.length} carried, `
192
- + `${verified.degraded.missing.length} missing); a code release does not ship one (ADR-0091 D10)`);
192
+ + `${verified.degraded.missing.length} missing); a code release ships only an all-CURRENT corpus -- its install `
193
+ + 'verification (scripts/public-verification-aggregate.mjs) requires every eligible store CURRENT');
193
194
  }
194
195
  const before = treeDigest(assets);
195
196
  // build-bundle reads the sealed coverage from the ONE canonical path (<root>/data/source-coverage.json),
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ // scripts/codex-hook-wrapper-history.mjs — every byte-exact Codex hook bridge the Brain has ever shipped.
3
+ //
4
+ // WHY (owner's machine, 2026-10-17). bin/install.mjs trusts an existing ~/.cache/ruvnet-brain/codex-hook.mjs
5
+ // only when it equals the NEW source or a wrapper in a RETAINED Stable Spine generation (versions/<v>/).
6
+ // A bridge written by an older release whose generation has since been pruned (for example the 4.5.2
7
+ // wrapper, sha256 d3ee284722aa…, written while stale 4.5.2 code ran) is still the Brain's own bytes, but
8
+ // nothing on disk proves it any more, so every update reported hook-wrapper-ownership-conflict and failed
9
+ // host convergence night after night. The proof that was missing is HISTORICAL: the set of wrapper bytes
10
+ // each shipped tag carried. That set is derived here from git, committed as data/codex-hook-wrapper-history.json
11
+ // (shipped in the npm package), and checked against git in the unit suite so it cannot drift.
12
+ //
13
+ // It proves only identity of BYTES. Links, hard links, another uid, a foreign path and edited bytes stay
14
+ // conflicts: those checks run first in legacyBrainWrapperOwnership and are untouched.
15
+ //
16
+ // node scripts/codex-hook-wrapper-history.mjs --write # regenerate data/codex-hook-wrapper-history.json
17
+ // node scripts/codex-hook-wrapper-history.mjs --check # exit 1 when the committed file differs from git
18
+
19
+ import crypto from 'node:crypto';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { execFileSync } from 'node:child_process';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ export const HISTORY_FILE = path.join('data', 'codex-hook-wrapper-history.json');
27
+ export const HISTORY_KIND = 'ruvnet-brain-codex-hook-wrapper-history';
28
+ export const WRAPPER_SOURCE = 'plugin/scripts/codex-hook-wrapper.mjs';
29
+ const RELEASE_TAG = /^v\d+\.\d+\.\d+$/;
30
+
31
+ const semver = (tag) => tag.slice(1).split('.').map(Number);
32
+ const byVersion = (a, b) => { const [x, y] = [semver(a), semver(b)]; return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; };
33
+
34
+ function git(root, args) {
35
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
36
+ }
37
+
38
+ /** Release tags (vX.Y.Z) in this checkout, ascending. Empty when git or the tags are unavailable. */
39
+ export function releaseTags(root = ROOT) {
40
+ try {
41
+ return git(root, ['tag', '-l', 'v*']).split('\n').map((line) => line.trim()).filter((tag) => RELEASE_TAG.test(tag)).sort(byVersion);
42
+ } catch { return []; }
43
+ }
44
+
45
+ /** { sha256, bytes, tags } for every distinct wrapper the release tags carried, ordered by first shipping tag. */
46
+ export function deriveWrapperHistory(root = ROOT) {
47
+ const tags = releaseTags(root);
48
+ const wrappers = new Map();
49
+ for (const tag of tags) {
50
+ let bytes;
51
+ try { bytes = execFileSync('git', ['show', `${tag}:${WRAPPER_SOURCE}`], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024 }); }
52
+ catch { continue; } // a tag that predates the wrapper shipped no bridge
53
+ const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
54
+ if (!wrappers.has(sha256)) wrappers.set(sha256, { sha256, bytes: bytes.length, tags: [] });
55
+ wrappers.get(sha256).tags.push(tag);
56
+ }
57
+ return {
58
+ schemaVersion: 1,
59
+ kind: HISTORY_KIND,
60
+ source: WRAPPER_SOURCE,
61
+ releaseTags: tags.length,
62
+ wrappers: [...wrappers.values()],
63
+ };
64
+ }
65
+
66
+ export function readWrapperHistory(root = ROOT) {
67
+ const history = JSON.parse(fs.readFileSync(path.join(root, HISTORY_FILE), 'utf8'));
68
+ if (history?.schemaVersion !== 1 || history.kind !== HISTORY_KIND || !Array.isArray(history.wrappers)) {
69
+ throw new Error(`${HISTORY_FILE} is not a schema-1 ${HISTORY_KIND}`);
70
+ }
71
+ return history;
72
+ }
73
+
74
+ /**
75
+ * How the committed history differs from git: `missing` = wrappers a release tag shipped that the file lacks
76
+ * (an upgrade of those bytes would be a conflict again); `extra` = rows in the file no release tag shipped
77
+ * (bytes the installer would trust that git never did -- a hand-added row is exactly the forgery this must name).
78
+ */
79
+ export function historyDrift(committed, derived) {
80
+ const key = (row) => `${row.sha256}:${row.bytes}`;
81
+ const known = new Set((committed.wrappers || []).map(key));
82
+ const shipped = new Set(derived.wrappers.map(key));
83
+ return {
84
+ missing: derived.wrappers.filter((row) => !known.has(key(row))),
85
+ extra: (committed.wrappers || []).filter((row) => !shipped.has(key(row))),
86
+ };
87
+ }
88
+ /** Kept for callers that only ask the one-sided question. */
89
+ export function missingWrappers(committed, derived) { return historyDrift(committed, derived).missing; }
90
+ export function describeDrift({ missing, extra }) {
91
+ const name = (row) => `${String(row.sha256 || '').slice(0, 12)} (${row.tags?.[0] ?? 'no tag'})`;
92
+ return [missing.length ? `missing ${missing.map(name).join(', ')}` : '', extra.length ? `not shipped by any release tag: ${extra.map(name).join(', ')}` : '']
93
+ .filter(Boolean).join('; ');
94
+ }
95
+
96
+ export function main(argv = process.argv.slice(2)) {
97
+ const derived = deriveWrapperHistory();
98
+ if (!derived.releaseTags) {
99
+ console.error('[wrapper-history] no vX.Y.Z tags are visible to git here; nothing to derive');
100
+ return 1;
101
+ }
102
+ const text = `${JSON.stringify(derived, null, 2)}\n`;
103
+ const file = path.join(ROOT, HISTORY_FILE);
104
+ if (argv.includes('--write')) {
105
+ fs.writeFileSync(file, text);
106
+ console.log(`wrote ${HISTORY_FILE}: ${derived.wrappers.length} distinct wrapper(s) across ${derived.releaseTags} release tag(s)`);
107
+ return 0;
108
+ }
109
+ if (argv.includes('--check')) {
110
+ // The BYTES are the contract; the tag lists are provenance. A new release that ships an unchanged wrapper
111
+ // adds a tag without adding a wrapper and must not fail this check at the next checkout.
112
+ let drift;
113
+ try { drift = historyDrift(readWrapperHistory(ROOT), derived); } catch (error) { console.error(`[wrapper-history] ${error.message}`); return 1; }
114
+ if (!drift.missing.length && !drift.extra.length) {
115
+ console.log(`${HISTORY_FILE} carries exactly the wrappers git's release tags shipped (${derived.wrappers.length} wrapper(s), ${derived.releaseTags} tag(s))`);
116
+ return 0;
117
+ }
118
+ console.error(`${HISTORY_FILE} differs from git: ${describeDrift(drift)}; run: node scripts/codex-hook-wrapper-history.mjs --write`);
119
+ return 1;
120
+ }
121
+ console.error('usage: codex-hook-wrapper-history.mjs --write | --check');
122
+ return 2;
123
+ }
124
+
125
+ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) {
126
+ process.exitCode = main();
127
+ }
@@ -46,6 +46,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
46
46
  import { addPrivateStore, resolveRuntime, resultFromRefreshReceipt, runInstallerDoor } from './customer-seams.mjs';
47
47
  import { CANARY_VERDICT_KIND, CORPUS_TAG_PATTERN, REQUIRED_CANARY_CHECKS } from './corpus-promotion.mjs';
48
48
  import { inventoryFootprint } from '../plugin/scripts/brain-footprint.mjs';
49
+ import { eligibleRepositoryStanding } from '../plugin/scripts/coverage-integrity.mjs';
49
50
 
50
51
  export const PUBLIC_API = 'https://api.github.com';
51
52
  export const FRESHNESS_LIMIT_MS = 48 * 3_600_000;
@@ -126,6 +127,13 @@ function coverageRows(file) {
126
127
  * 2026-09-30), so a "moved store must be ingested within 48h of now" rule would wrongly refuse a
127
128
  * perfectly fresh generation about three nights after every code release.
128
129
  * MISSING/INELIGIBLE rows are the coverage validator's business (it already ran), not a freshness question.
130
+ *
131
+ * CARRIED STORES (ADR-0091 D5, decided 2026-10-17): a repository row the shipped validator accepts as
132
+ * `shipped` WITH a `carry` record -- STALE, bytes re-hashed against the ledger, `carry.carriedSourceCommit`
133
+ * equal to the shipped commit and `carry.missedUpstream` equal to the observed upstream -- is not a stale
134
+ * store: the generation names the miss and keeps the last verified bytes on purpose. It is reported here by
135
+ * name, never silently. A row whose shipped commit differs from its upstream WITHOUT that record is still
136
+ * stale and still fails: that is the shape an unrecorded miss has.
129
137
  */
130
138
  export function storeFreshness({ after, before = null, now }) {
131
139
  if (!after) return { ok: false, detail: 'CORPUS-COVERAGE.json is missing from the installed tree' };
@@ -136,19 +144,25 @@ export function storeFreshness({ after, before = null, now }) {
136
144
  if (age > FRESHNESS_LIMIT_MS || age < 0) problems.push(`upstream observed ${(age / 3_600_000).toFixed(1)}h ago (limit 48h)`);
137
145
  const priorCommit = new Map((before?.rows || []).map((row) => [row.key, row.artifact?.sourceCommit || null]));
138
146
  const stale = [];
147
+ const carried = [];
139
148
  let moved = 0;
140
149
  for (const row of after.rows) {
141
150
  if (row.status === 'INELIGIBLE') continue;
142
151
  const upstream = row.upstream?.sha || null;
143
152
  const built = row.artifact?.sourceCommit || null;
144
153
  if (!upstream || !built) continue;
145
- if (upstream !== built) { stale.push(row.artifact?.store || row.key); continue; }
154
+ if (upstream !== built) {
155
+ if (row.kind === 'repository' && row.carry && eligibleRepositoryStanding(row) === 'shipped') carried.push(row.artifact?.store || row.key);
156
+ else stale.push(row.artifact?.store || row.key);
157
+ continue;
158
+ }
146
159
  if (before && priorCommit.get(row.key) !== upstream) moved += 1;
147
160
  }
148
- if (stale.length) problems.push(`${stale.length} store(s) built from a commit older than their observed upstream: ${stale.slice(0, 10).join(', ')}`);
161
+ if (stale.length) problems.push(`${stale.length} store(s) built from a commit older than their observed upstream with no recorded carry: ${stale.slice(0, 10).join(', ')}`);
162
+ const carriedDetail = carried.length ? `${carried.length} carried at their last verified bytes with a recorded reason: ${carried.slice(0, 10).join(', ')}` : '0 carried';
149
163
  return problems.length
150
- ? { ok: false, detail: problems.join('; ') }
151
- : { ok: true, detail: `observed ${(age / 3_600_000).toFixed(1)}h ago; ${after.rows.length} row(s), 0 stale; ${before ? `${moved} moved since the installed generation, all at their observed upstream commit` : 'no prior coverage to diff against'}` };
164
+ ? { ok: false, detail: `${problems.join('; ')}; ${carriedDetail}` }
165
+ : { ok: true, detail: `observed ${(age / 3_600_000).toFixed(1)}h ago; ${after.rows.length} row(s), 0 stale, ${carriedDetail}; ${before ? `${moved} moved since the installed generation, all at their observed upstream commit` : 'no prior coverage to diff against'}` };
152
166
  }
153
167
 
154
168
  /**
@@ -11,7 +11,9 @@
11
11
  // and the receipt binds none, so nothing downstream of preparation could tell a degraded generation
12
12
  // (carried / missing stores) from a fully current one. The coverage lived only in a 14-day workflow
13
13
  // artifact. D6's code-release path needs the generation's sealed coverage to assemble single-pass,
14
- // and D10's publisher-side "no degraded generation before the soak" check needs it to exist at all.
14
+ // the publisher (release.mjs) binds it to the receipt and names a degraded generation's stores from it,
15
+ // and the watchdog's 7-day carry rule and corpus-next-seed's code-release refusal of a degraded seed
16
+ // both read it. (The former ADR-0091 D10 soak gate also read it; that gate was removed 2026-10-17.)
15
17
  //
16
18
  // CORPUS-COVERAGE.json -- the sealed ruvnet-brain-corpus-coverage ledger, exact bytes.
17
19
  // coverage-receipt.json -- { generationTag, archiveSha256, archiveBytes, coverageSha256, ... }.
@@ -148,8 +148,8 @@ const sha256Of = (file) => crypto.createHash('sha256').update(fs.readFileSync(fi
148
148
  function judgeRelease({ run, repo, tag, digest, profile, rejected, requireCoverage = false }) {
149
149
  // `indeterminate` marks a rejection that says nothing about the release itself -- the network or
150
150
  // `gh` failed. A publish-time re-check (ADR-0091 D6.6) must not read one as "no newer generation".
151
- const reject = (reason, { indeterminate = false } = {}) => {
152
- rejected.push({ tag, reason, ...(indeterminate ? { indeterminate: true } : {}) });
151
+ const reject = (reason, { indeterminate = false, kind = null } = {}) => {
152
+ rejected.push({ tag, reason, ...(indeterminate ? { indeterminate: true } : {}), ...(kind ? { kind } : {}) });
153
153
  return null;
154
154
  };
155
155
  let view;
@@ -250,6 +250,18 @@ function judgeRelease({ run, repo, tag, digest, profile, rejected, requireCovera
250
250
  } catch (error) {
251
251
  return reject(`unverified: coverage sidecar does not bind this generation (${error.message})`);
252
252
  }
253
+ // A CODE release ships only an all-CURRENT corpus. Its install verification
254
+ // (scripts/public-verification-aggregate.mjs) requires coverage.eligibleCurrent === eligibleTotal on
255
+ // every lane, so a release assembled from a degraded generation could never become install-verified
256
+ // and the nightly would stand down on it. The nightly seed may be degraded: the next night re-plans
257
+ // the carried stores. Decided 2026-10-17 with the D10 removal.
258
+ const degradedCount = coverage.degraded.carried.length + coverage.degraded.missing.length;
259
+ if (requireCoverage && degradedCount > 0) {
260
+ return reject(`incompatible for a code release: degraded generation (${coverage.degraded.carried.length} carried: `
261
+ + `${coverage.degraded.carried.join(', ') || 'none'}; ${coverage.degraded.missing.length} missing: `
262
+ + `${coverage.degraded.missing.join(', ') || 'none'}); install verification requires every eligible store CURRENT`,
263
+ { kind: 'degraded' });
264
+ }
253
265
  }
254
266
 
255
267
  try {
@@ -303,7 +315,7 @@ export async function resolveNextCorpusSeed({
303
315
  const rejected = [];
304
316
  let listed = [];
305
317
  try {
306
- listed = ghJson(run, ['release', 'list', '--repo', repo, '--limit', String(limit), '--json', 'tagName,isDraft,createdAt']) || [];
318
+ listed = ghJson(run, ['release', 'list', '--repo', repo, '--limit', String(limit), '--json', 'tagName,isDraft,isPrerelease,createdAt']) || [];
307
319
  } catch (error) {
308
320
  rejected.push({ tag: null, reason: `release list failed (${error.message})`, indeterminate: true });
309
321
  }
@@ -316,21 +328,38 @@ export async function resolveNextCorpusSeed({
316
328
  const candidates = [];
317
329
  for (const row of corpusShaped) {
318
330
  if (row.isDraft) { rejected.push({ tag: row.tagName, reason: 'unverified: release is still a draft' }); continue; }
331
+ // A corpus generation is STAGED as a prerelease and PROMOTED (prerelease flag cleared) only after the
332
+ // customer canary applied it (protected-release.yml corpus-canary -> corpus-promote). A generation
333
+ // still a prerelease was refused by that canary, superseded, or is mid-flight: no customer received
334
+ // it, so no night and no code release may build on it (reviewer finding, 2026-10-17). The committed
335
+ // bootstrap is a code-release tag, never corpus-shaped, so this never touches it.
336
+ if (row.isPrerelease) { rejected.push({ tag: row.tagName, reason: 'unpromoted: still a prerelease (the customer canary never promoted it); not a seed' }); continue; }
319
337
  const createdAt = Date.parse(row.createdAt);
320
338
  if (!Number.isFinite(createdAt)) { rejected.push({ tag: row.tagName, reason: 'unverified: release has no readable publication time' }); continue; }
321
339
  candidates.push({ tag: row.tagName, digest: CORPUS_TAG.exec(row.tagName)[1], createdAt });
322
340
  }
323
341
  candidates.sort((left, right) => right.createdAt - left.createdAt);
324
342
 
325
- const judged = candidates.slice(0, searchBound);
326
- for (const skipped of candidates.slice(searchBound)) {
327
- rejected.push({ tag: skipped.tag, reason: `not judged: older than the ${searchBound} newest generations (ADR-0091 D4 search bound)` });
328
- }
329
343
  const expects = { model: compatibility.model, dimensions: compatibility.dimensions, fixtureSha256: compatibility.fixtureSha256 };
330
- for (const candidate of judged) {
344
+ // The bound counts generations JUDGED incompatible or unverifiable (ADR-0091 D4: one bad format change can
345
+ // cost at most SEARCH_BOUND downloads). In code-release mode a DEGRADED promoted generation is skipped
346
+ // without consuming it: a carried store is a transient state the next night re-plans, and if each such
347
+ // night used up the bound, SEARCH_BOUND degraded nights in a row would walk every code release back to the
348
+ // committed bootstrap (a pre-ADR-0091 corpus) with nothing paging (reviewer finding, 2026-10-17). The
349
+ // release listing (`--limit`) remains the absolute cap on the walk.
350
+ let judged = 0;
351
+ let skippedDegraded = 0;
352
+ for (const candidate of candidates) {
353
+ if (judged >= searchBound) {
354
+ rejected.push({ tag: candidate.tag, reason: `not judged: older than the ${searchBound} newest generations (ADR-0091 D4 search bound)` });
355
+ continue;
356
+ }
331
357
  const resolved = judgeRelease({ run, repo, tag: candidate.tag, digest: candidate.digest, profile: compatibility, rejected,
332
358
  requireCoverage });
333
- if (resolved) return { seed: resolved, rejected, judged: judged.length, expects };
359
+ if (resolved) return { seed: resolved, rejected, judged: judged + 1, skippedDegraded, expects };
360
+ if (requireCoverage && rejected[rejected.length - 1]?.tag === candidate.tag && rejected[rejected.length - 1]?.kind === 'degraded') {
361
+ skippedDegraded += 1;
362
+ } else judged += 1;
334
363
  }
335
364
 
336
365
  return {
@@ -344,7 +373,8 @@ export async function resolveNextCorpusSeed({
344
373
  brainVersion: null,
345
374
  },
346
375
  rejected,
347
- judged: judged.length,
376
+ judged,
377
+ skippedDegraded,
348
378
  expects,
349
379
  };
350
380
  }
@@ -18,7 +18,7 @@ import { rebuildCorpusAggregates } from './corpus-aggregates.mjs';
18
18
  import { assertCapabilityOnlyStore, isCapabilityOnly, CAPABILITY_RETIRED_SUFFIXES } from '../kb/capability-only.mjs';
19
19
  import { eligibleRepositoryStanding, fileIdentity, validateCoverageLedger } from '../plugin/scripts/coverage-integrity.mjs';
20
20
  import {
21
- CORPUS_QA_FAILED_EXIT, FAILURE_CLASS, StoreWorkerError, degradedBound, degradedPublication, failureReason, isRetryable,
21
+ CORPUS_QA_FAILED_EXIT, FAILURE_CLASS, StoreWorkerError, degradedBound, describeDegraded, failureReason, isRetryable,
22
22
  } from './corpus-store-failure.mjs';
23
23
  import { readDiagnosticAccuracyReport } from './oracle/retrieval-accuracy.mjs';
24
24
  import { storeRoot } from '../kb/store-root.mjs';
@@ -132,10 +132,6 @@ function filesNamed(root, wanted) {
132
132
  // corpus-seed.yml can re-run seed extraction ONCE from the committed bootstrap in the same job.
133
133
  export const SEED_LEDGER_SCHEMA_VERSION = 2;
134
134
  export const SEED_LEDGER_INCOMPATIBLE_EXIT = 3;
135
- // ADR-0091 D5 + D10: the generation was built and SEALED with carried/missing stores, but degraded
136
- // publication is not yet allowed (no soaked tolerant-validator transition). It must not be published;
137
- // the next night re-plans the carried stores automatically because their sourceCommit still differs.
138
- export const DEGRADED_UNPUBLISHED_EXIT = 4;
139
135
 
140
136
  export class SeedLedgerIncompatibleError extends Error {
141
137
  constructor(reason) {
@@ -1256,17 +1252,13 @@ export async function main(argv = process.argv.slice(2), {
1256
1252
  return 0;
1257
1253
  }
1258
1254
  const { plan } = summarizeReconciliation(reconciliation);
1255
+ // ADR-0091 D5: a generation inside the degraded bound (assessCandidateCoverage already refused one
1256
+ // above it) is sealed and goes on to be published like any other; the night's log and step summary
1257
+ // say which stores were carried or are missing, and each row says so in the sealed coverage.
1259
1258
  const degraded = candidate.degraded || { carried: [], missing: [] };
1260
- const isDegraded = degraded.carried.length + degraded.missing.length > 0;
1261
- const publication = isDegraded ? degradedPublication() : { allowed: true, reason: 'every eligible row is CURRENT' };
1262
- stdout.write(`${JSON.stringify({ ok: publication.allowed, seedTag, seedSha256, plan, reconciliation, ...candidate,
1263
- degraded: { ...degraded, publishable: publication.allowed, reason: publication.reason } }, null, 2)}\n`);
1264
- if (!publication.allowed) {
1265
- stderr.write(`::warning title=Degraded corpus generation sealed, not published::${degraded.carried.length} carried `
1266
- + `(${degraded.carried.map((row) => row.store).join(', ') || 'none'}), ${degraded.missing.length} missing `
1267
- + `(${degraded.missing.map((row) => row.store).join(', ') || 'none'}); ${publication.reason}\n`);
1268
- return DEGRADED_UNPUBLISHED_EXIT;
1269
- }
1259
+ const summary = describeDegraded(degraded);
1260
+ stdout.write(`${JSON.stringify({ ok: true, seedTag, seedSha256, plan, reconciliation, ...candidate, degraded }, null, 2)}\n`);
1261
+ if (summary) stderr.write(`::warning title=Degraded corpus generation::${summary}; the carried stores keep their last verified bytes and are re-planned next night\n`);
1270
1262
  return 0;
1271
1263
  }
1272
1264
 
@@ -1,6 +1,6 @@
1
1
  // ADR-0091 D5 — per-store failure isolation for corpus reconciliation.
2
2
  //
3
- // One store failing to refresh must not abort the whole generation. This module owns the three
3
+ // One store failing to refresh must not abort the whole generation. This module owns the two
4
4
  // pure decisions that make that safe, so the reconciler, the rehearsal and the tests share them:
5
5
  //
6
6
  // 1. WHICH failures are retried. Only transient ones (clone, fetch, runner I/O), exactly once, in a
@@ -10,11 +10,25 @@
10
10
  // from log text.
11
11
  // 2. HOW MANY stores may be carried or missing before the failure is systemic and the generation
12
12
  // must fail loudly: carried + missing <= max(3, 5% of eligible).
13
- // 3. WHETHER a degraded generation may be published at all. Installed clients judge every bundle
14
- // with their INSTALLED validator, and every validator shipped before this change rejects any
15
- // non-CURRENT eligible row. ADR-0091 D10 therefore gates degraded publication behind a
16
- // transition release plus a 14-day soak; until D10 records that transition, a degraded
17
- // generation is built and sealed but never published.
13
+ //
14
+ // A generation inside that bound PUBLISHES like any other, each carried or missing store named in its
15
+ // sealed coverage (CORPUS-COVERAGE.json: `carry` / `failure` records). Whether installed clients accept
16
+ // it is proven every night by the customer canary (protected-release.yml `customer-canary`: a clean
17
+ // install, a private-overlay install and the previous runtime each apply the staged candidate through
18
+ // the real updater before anything is promoted; scripts/corpus-canary.mjs storeFreshness names each
19
+ // carried store), not by a hand-set constant. The former ADR-0091 D10 gate -- a committed
20
+ // `VALIDATOR_TRANSITION` plus a 14-day soak -- was removed on 2026-10-17: the tolerant validator
21
+ // (`eligibleRepositoryStanding`) has shipped in every code release since v4.3.36 (2026-09-29) and every
22
+ // corpus generation since 2026-10-01, the soak it demanded ended on 2026-10-13, and the constant was
23
+ // never set. Measured on the one night it decided (run 39069376585, 2026-10-17, runtime 4.7.10): the
24
+ // external source cognitum-one/ruOS had been deleted upstream; 4.7.10's `ifGone` entry carried it at
25
+ // b1762a71 while the live corpus served 576dc8c8, so the store was re-planned, the clone of the gone
26
+ // repository failed twice, the store was carried, the 201-store generation was built and sealed over
27
+ // ~1h40m, and this gate threw it away with exit 4 (4.7.11 fixed the `ifGone` commit). The four nights
28
+ // before it (38529261934, 38699673841, 38867855193, 39013743592) died earlier, exit 1, on the HTTP 404
29
+ // itself. A gate that only a code release can open, that nobody opens, is not a safety property.
30
+ // A CODE release still ships only an all-CURRENT corpus: scripts/corpus-next-seed.mjs --require-coverage
31
+ // refuses a degraded seed, because install verification requires eligibleCurrent === eligibleTotal.
18
32
 
19
33
  /** kb/forge-refresh.mjs exits with this status when corpus-qa rejected its candidate. */
20
34
  export const CORPUS_QA_FAILED_EXIT = 3;
@@ -57,26 +71,11 @@ export function degradedBound(eligibleCount) {
57
71
  }
58
72
 
59
73
  /**
60
- * ADR-0091 D10 owns this value. It stays null until the transition release N (the first release
61
- * shipping the tolerant validator while its own coverage is all-CURRENT) has become releases/latest;
62
- * D10 then records { version, latestSince } here. Null means no transition: never publish degraded.
74
+ * One line a run log or step summary can print for a generation's carried/missing stores; '' when none.
75
+ * Rows are the reconciler's `{ store, ... }` records or the coverage sidecar's bare store names.
63
76
  */
64
- export const VALIDATOR_TRANSITION = null;
65
- export const TRANSITION_SOAK_DAYS = 14;
66
-
67
- export function degradedPublication({ transition = VALIDATOR_TRANSITION, now = new Date() } = {}) {
68
- if (!transition) {
69
- return { allowed: false,
70
- reason: 'no tolerant-validator transition release is recorded (ADR-0091 D10); installed clients would reject a non-CURRENT row' };
71
- }
72
- const since = Date.parse(transition.latestSince);
73
- if (typeof transition.version !== 'string' || !transition.version || !Number.isFinite(since)) {
74
- return { allowed: false, reason: 'the recorded validator transition is malformed' };
75
- }
76
- const soakEnds = since + TRANSITION_SOAK_DAYS * 24 * 60 * 60 * 1000;
77
- if (now.getTime() < soakEnds) {
78
- return { allowed: false,
79
- reason: `validator transition ${transition.version} soaks until ${new Date(soakEnds).toISOString()} (ADR-0091 D10)` };
80
- }
81
- return { allowed: true, reason: `validator transition ${transition.version} has soaked ${TRANSITION_SOAK_DAYS} days` };
77
+ export function describeDegraded({ carried = [], missing = [] } = {}) {
78
+ if (!carried.length && !missing.length) return '';
79
+ const names = (rows) => rows.map((row) => (typeof row === 'string' ? row : row.store)).join(', ') || 'none';
80
+ return `${carried.length} carried (${names(carried)}), ${missing.length} missing (${names(missing)})`;
82
81
  }
@@ -188,7 +188,21 @@ export function judgeCorpusHealth(input, now) {
188
188
  // (c) the newest code release must reach a verified aggregate within 24h of publish.
189
189
  const code = input?.codeRelease;
190
190
  if (!code) add(RED, 'code-aggregate', 'no published vX.Y.Z code release was found');
191
- else if (code.aggregate?.state === 'verified') add(GREEN, 'code-aggregate', `${code.tag} carries a verified PASS aggregate`);
191
+ else if (code.aggregate?.state === 'verified') {
192
+ add(GREEN, 'code-aggregate', `${code.tag} carries a verified PASS aggregate`);
193
+ // (c2) THE CORPUS FRESH INSTALLS RECEIVE. A code release ships a corpus too, and its signed aggregate
194
+ // records how many eligible stores the three install lanes found CURRENT. The aggregate VERIFIES only when
195
+ // eligibleCurrent === eligibleTotal (scripts/public-verification-aggregate.mjs), so a verified code release
196
+ // never ships a carried or missing store; this line says so with the measured counts rather than leaving
197
+ // it implied. Counts that are absent (the verifier does not require their presence) are reported, not
198
+ // read as "all current".
199
+ const shipped = code.aggregate.coverage;
200
+ if (!shipped || !Number.isSafeInteger(shipped.eligibleTotal) || !Number.isSafeInteger(shipped.eligibleCurrent)) {
201
+ add(WARNING, 'code-corpus', `${code.tag}: the aggregate records no eligible/CURRENT store counts; what fresh installs receive is unmeasured`);
202
+ } else {
203
+ add(GREEN, 'code-corpus', `${code.tag} ships ${shipped.eligibleCurrent}/${shipped.eligibleTotal} eligible stores CURRENT`);
204
+ }
205
+ }
192
206
  else if (code.aggregate?.state === 'invalid') add(RED, 'code-aggregate', `${code.tag} carries an aggregate that does not verify `
193
207
  + `under this checkout's verifier (the one approved-runtime --resolve runs, so the nightly will refuse it too): ${code.aggregate.reason}`);
194
208
  else {
@@ -210,7 +224,10 @@ export function judgeCorpusHealth(input, now) {
210
224
  .map((row) => `${row.store} (${row.lowerBound ? '≥' : ''}${hours(age(row.since))})`).join(', ')}`);
211
225
  } else add(GREEN, 'deferral', `${(deferral.stores || []).length} deferred store(s), none older than 7 days`);
212
226
  const degraded = [...(deferral.degraded?.carried || []), ...(deferral.degraded?.missing || [])];
213
- if (degraded.length) add(WARNING, 'degraded', `${deferral.newestTag} is degraded: ${deferral.degraded.carried.length} carried, ${deferral.degraded.missing.length} missing`);
227
+ if (degraded.length) {
228
+ add(WARNING, 'degraded', `${deferral.newestTag} is degraded: ${deferral.degraded.carried.length} carried `
229
+ + `(${deferral.degraded.carried.join(', ') || 'none'}), ${deferral.degraded.missing.length} missing (${deferral.degraded.missing.join(', ') || 'none'})`);
230
+ }
214
231
  }
215
232
 
216
233
  // (e) the generation customers receive. Its own generation stamp when it carries one (the corpus
@@ -267,7 +284,12 @@ async function aggregateState({ gh, repo, tag, root, scratch }) {
267
284
  const { verifyPublicVerificationAggregate } = await import('./public-verification-aggregate.mjs');
268
285
  verifyPublicVerificationAggregate(aggregate, crypto.createPublicKey(fs.readFileSync(path.join(root, SIGNING_PUBLIC_KEY_FILE), 'utf8')));
269
286
  if (aggregate.identity?.tag !== tag) throw new Error(`aggregate describes ${aggregate.identity?.tag}, not ${tag}`);
270
- return { state: 'verified' };
287
+ // The corpus a CODE release ships, as its three install lanes measured it: the signed aggregate carries
288
+ // the eligible/CURRENT counts and the coverage generation of the installed COVERAGE.json. The code
289
+ // release publishes no coverage sidecar of its own (verified 2026-10-17: v4.7.10 assets), so this is
290
+ // the one small, signed place that says what fresh installs receive.
291
+ return { state: 'verified', coverage: { eligibleTotal: aggregate.coverage?.eligibleTotal ?? null,
292
+ eligibleCurrent: aggregate.coverage?.eligibleCurrent ?? null, coverageGeneration: aggregate.identity?.coverageGeneration ?? null } };
271
293
  } catch (error) {
272
294
  return { state: 'invalid', reason: error.message.slice(0, 200) };
273
295
  }
@@ -917,17 +917,11 @@ async function runGeneration({ index, api, checkoutRoot, workRoot, seed, bounds,
917
917
  });
918
918
  log(`[gen ${index}] extracted-byte verification passed with staging revoked (${generation.extractedByteVerification.files} files)`);
919
919
 
920
- // --- ADR-0091 D5 + D10: a degraded generation is sealed but not published until D10 allows it ---
920
+ // --- ADR-0091 D5: a degraded generation inside the bound is published like any other, named -----
921
921
  const corpusTag = `corpus-sha256-${archiveSha256}`;
922
922
  if (generation.degraded.carried.length || generation.degraded.missing.length) {
923
- const decision = api.degradedPublication();
924
- if (!decision.allowed) {
925
- generation.publication = { status: 'WITHHELD-DEGRADED', corpusTag, reason: decision.reason,
926
- carried: generation.degraded.carried.map((entry) => entry.store), missing: generation.degraded.missing.map((entry) => entry.store) };
927
- generation.finishedAt = new Date().toISOString();
928
- log(`[gen ${index}] degraded generation sealed and WITHHELD from publication: ${decision.reason}`);
929
- return { generation, withheld: true, nextSeed: null, published: null };
930
- }
923
+ log(`[gen ${index}] degraded generation (${generation.degraded.carried.length} carried, `
924
+ + `${generation.degraded.missing.length} missing) is published with its stores named in the sealed coverage`);
931
925
  }
932
926
 
933
927
  // --- rehearse the real product-consumption path, publication RECORDED not executed -----------
@@ -1371,7 +1365,6 @@ export async function rehearseCorpusPipeline({
1371
1365
  prepareCorpusCandidate: reconcileMod.prepareCorpusCandidate,
1372
1366
  executeReconciliation: reconcileMod.executeReconciliation,
1373
1367
  defaultRunAsync: reconcileMod.defaultRunAsync,
1374
- degradedPublication: failureMod.degradedPublication,
1375
1368
  observeSourceUniverse: coverageMod.observeSourceUniverse,
1376
1369
  canonicalSourceObservation: coverageMod.canonicalSourceObservation,
1377
1370
  buildCoverage: coverageMod.buildCoverage,
@@ -1479,14 +1472,6 @@ export async function rehearseCorpusPipeline({
1479
1472
  phase(`generation-${index}-approved-runtime`, 'PASS', { ...runtimeCheck,
1480
1473
  reason: `verifyApprovedRuntime PASS: ${verdict.checked} runtime files equal the checkout's, none unpinned, seed ${seed.tag} did not leak an executable` });
1481
1474
 
1482
- if (result.withheld) {
1483
- phase(`generation-${index}-publication`, 'PASS', { status: 'WITHHELD-DEGRADED', ...result.generation.publication,
1484
- reason: `degraded generation sealed but not published (ADR-0091 D5/D10): ${result.generation.publication.reason}` });
1485
- if (index < generations) {
1486
- phase('remaining-generations', 'SKIP', { reason: `generation ${index} was withheld, so there is no published generation ${index} to seed from` });
1487
- }
1488
- break;
1489
- }
1490
1475
  const consumeNow = seedSelection && codeReleaseConsumption && !codeRelease;
1491
1476
  if (seedSelection && (index < generations || codeRelease)) {
1492
1477
  lastPublished = publishLocally({ selectionMod, registryDir, published: result.published, workRoot, index });
@@ -69,7 +69,7 @@ import {
69
69
  CORPUS_GENERATION_FIELD, CORPUS_TAG_PATTERN, evaluateCanaryVerdict, evaluateCorpusPromotion, parseCorpusGeneration,
70
70
  } from './corpus-promotion.mjs';
71
71
  import { bindCoverageToReceipt, writeCoverageAssets } from './corpus-coverage-sidecar.mjs';
72
- import { degradedPublication } from './corpus-store-failure.mjs';
72
+ import { describeDegraded } from './corpus-store-failure.mjs';
73
73
  import { assertNoNewerCorpusGeneration } from './code-release-corpus.mjs';
74
74
 
75
75
  const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
@@ -404,12 +404,12 @@ export async function runProtectedCorpusSeed({
404
404
  corpusFailure(`corpus receipt does not verify against the sealed archive (${error.message})`);
405
405
  }
406
406
 
407
- // ADR-0091 D6.2 + D10. The archive carries no coverage and the schema-3 receipt binds none, so this
408
- // is the one place the publisher can SEE whether the generation is degraded. The coverage must be
409
- // the coverage of THIS archive (bound store by store to the receipt), it is published beside the
410
- // archive as CORPUS-COVERAGE.json + coverage-receipt.json (no receipt schema bump), and a
411
- // generation with any carried or missing store is refused while D10 has recorded no soaked
412
- // tolerant-validator transition -- installed clients would reject it. Local, before any network.
407
+ // ADR-0091 D6.2. The archive carries no coverage and the schema-3 receipt binds none, so this is the
408
+ // one place the publisher can SEE whether the generation is degraded. The coverage must be the
409
+ // coverage of THIS archive (bound store by store to the receipt) and is published beside the archive
410
+ // as CORPUS-COVERAGE.json + coverage-receipt.json (no receipt schema bump). A generation inside the
411
+ // degraded bound is staged like any other: the customer canary, not a constant, decides whether
412
+ // installed clients accept it before it is promoted. Local, before any network.
413
413
  let coverageAssets;
414
414
  try {
415
415
  coverageAssets = writeCoverageAssets({
@@ -419,13 +419,8 @@ export async function runProtectedCorpusSeed({
419
419
  const degraded = bindCoverageToReceipt({
420
420
  coverage: JSON.parse(fs.readFileSync(coverageAssets.coverageFile, 'utf8')), receipt,
421
421
  });
422
- if (degraded.carried.length + degraded.missing.length > 0) {
423
- const decision = degradedPublication();
424
- if (!decision.allowed) {
425
- corpusFailure(`degraded generation (${degraded.carried.length} carried, ${degraded.missing.length} missing) `
426
- + `must not be published: ${decision.reason}`);
427
- }
428
- }
422
+ const summary = describeDegraded(degraded);
423
+ if (summary) console.error(`[corpus-seed] degraded generation staged: ${summary} (each named in CORPUS-COVERAGE.json)`);
429
424
  } catch (error) {
430
425
  if (String(error.message).startsWith('[corpus-seed]')) throw error;
431
426
  corpusFailure(`the generation's sealed coverage does not bind this archive (${error.message})`);
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url';
17
17
  import { readCheckpoint, checkpointStaleness } from './loop-checkpoint.mjs';
18
18
  import { PRODUCTION_ENVIRONMENT, productionEnvironmentVerdict } from './release-environment-policy.mjs';
19
19
  import { approvalHits, LOCAL_INSTRUCTION_DRIFT } from './human-approval-phrases.mjs';
20
+ import { deriveWrapperHistory, describeDrift, historyDrift, readWrapperHistory } from './codex-hook-wrapper-history.mjs';
20
21
 
21
22
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
23
  const HOME = os.homedir();
@@ -192,6 +193,19 @@ const checks = [
192
193
  return none([...extra, ...drift]);
193
194
  } },
194
195
 
196
+ // The installer proves a pre-existing Codex hook bridge is the Brain's own by its bytes: the new source, a
197
+ // retained Spine generation, or (2026-10-17) any wrapper an earlier release tag shipped, from the committed
198
+ // data/codex-hook-wrapper-history.json. That file is derived from git; a release that ships NEW wrapper
199
+ // bytes without regenerating it would make the next upgrade of those bytes a conflict again.
200
+ { id: 'D4', area: 'corpus', scope: 'repo', title: 'The committed Codex hook wrapper history carries exactly the wrappers the release tags shipped',
201
+ run: () => {
202
+ const derived = deriveWrapperHistory(ROOT);
203
+ if (!derived.releaseTags) return { ok: true, detail: 'no vX.Y.Z tags visible to git here; nothing to compare' };
204
+ const drift = historyDrift(readWrapperHistory(ROOT), derived);
205
+ const ok = !drift.missing.length && !drift.extra.length;
206
+ return { ok, detail: ok ? `${derived.wrappers.length} wrapper(s) across ${derived.releaseTags} tag(s)`
207
+ : `${describeDrift(drift)}; run node scripts/codex-hook-wrapper-history.mjs --write` };
208
+ } },
195
209
  { id: 'D2', area: 'corpus', scope: 'machine', title: 'Installed knowledge matches the installed plugin version',
196
210
  run: () => {
197
211
  const tag = String(json(readAbs(path.join(HOME, '.cache/ruvnet-brain/kb/SOURCE.json'))).releaseTag || '').replace(/^v/, '');